Harden private media migration compatibility

This commit is contained in:
jack
2026-07-25 20:39:29 +02:00
committed by jack
parent 3f18768112
commit 86d8bb70f7
31 changed files with 2715 additions and 121 deletions
@@ -7,10 +7,9 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the async media-preparation pipelines (`ImageUtils`,
// `ChatMediaPreparation`) run real file/codec work and remain covered by
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
// bookkeeping, and the blocked-context guards.
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
// tests inject a paused voice-note preparer to exercise cancellation ownership
// across the detached-preparation/MainActor boundary deterministically.
//
import Testing
@@ -86,11 +85,64 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
transferId: String,
messageID: String
)] = []
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
private var pendingLegacyConsentIDs: [UUID] = []
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicy
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let id = UUID()
legacyConsentRequests.append((id, peerID, transferId, messageID))
pendingLegacyConsentIDs.append(id)
legacyConsentCompletions[id] = completion
}
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
invalidatedLegacyConsents.append((transferId, messageID))
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
request.transferId == transferId && request.messageID == messageID
? request.id
: nil
})
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
}
func resolveNextLegacyConsent(_ approved: Bool) {
guard !pendingLegacyConsentIDs.isEmpty else { return }
let id = pendingLegacyConsentIDs.removeFirst()
legacyConsentCompletions[id]?(approved)
}
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
legacyConsentCompletions[id]?(approved)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -102,6 +154,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
}
}
private final class PausedVoiceNotePreparer: @unchecked Sendable {
private let condition = NSCondition()
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
while !released {
condition.wait()
}
finished = true
condition.broadcast()
condition.unlock()
return packet
}
var hasStarted: Bool {
condition.lock()
defer { condition.unlock() }
return started
}
var hasFinished: Bool {
condition.lock()
defer { condition.unlock() }
return finished
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
@@ -166,6 +268,14 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
// A pre-start rejection keeps the placeholder visible and failed,
// including queued post-handshake encryption failures.
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
#expect(coordinator.messageIDToTransferId["m3"] == nil)
}
@Test @MainActor
@@ -203,6 +313,20 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
coordinator.deleteMediaMessage(messageID: "message-delete")
#expect(context.cancelledTransfers == ["approved-delete"])
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
@@ -221,4 +345,224 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.appendedPublicMessages.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "5566778899aabbcc")
context.selectedPrivateChatPeer = peerID
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.cancelMediaSend(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.deleteMediaMessage(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.broadcastFileSends.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "66778899aabbccdd")
context.selectedPrivateChatPeer = peerID
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { _ in
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
}
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{
context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
}
},
timeout: TestConstants.longTimeout
))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
}
@Test @MainActor
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
#expect(context.privateFileSends.isEmpty)
context.resolveNextLegacyConsent(true)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileLegacyAllowances == [true])
}
@Test @MainActor
func legacyConsentApprovalAfterCancelCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.cancelMediaSend(messageID: request.messageID)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
// Model a stale framework callback that escaped active invalidation.
// The coordinator's transfer/message binding check is the final gate.
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "33445566778899aa")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.deleteMediaMessage(messageID: request.messageID)
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .blockedDowngrade
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let failed = await TestHelpers.waitUntil(
{ context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
} },
timeout: TestConstants.longTimeout
)
#expect(failed)
#expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.isEmpty)
}
}
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "1111111111111111")
let secondPeer = PeerID(str: "2222222222222222")
var decisions: [Bool] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-1",
messageID: "message-1"
) { decisions.append($0) }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-2",
messageID: "message-2"
) { decisions.append($0) }
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
// A button action and the dialog binding may both resolve the first
// ID. The stale second callback must not consume the queued request.
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
#expect(decisions == [true])
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
#expect(decisions == [true, false])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "3333333333333333")
let secondPeer = PeerID(str: "4444444444444444")
var decisions: [String] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-cancelled",
messageID: "message-cancelled"
) { decisions.append("first:\($0)") }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-kept",
messageID: "message-kept"
) { decisions.append("second:\($0)") }
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.invalidateLegacyPrivateMediaConsent(
transferId: "transfer-cancelled",
messageID: "message-cancelled"
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
viewModel.resolveLegacyPrivateMediaConsent(
requestID: cancelledRequestID,
approved: true
)
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
#expect(decisions.isEmpty)
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
#expect(decisions == ["second:true"])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
@@ -11,47 +11,586 @@ import Testing
@Suite("Private media end to end", .serialized)
struct PrivateMediaEndToEndTests {
@Test
func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() async throws {
func privateMediaCancellationTombstonesAreCountBounded() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-tombstone-bound-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
for index in 0..<600 {
service.cancelTransfer("cancelled-before-admission-\(index)")
}
#expect(service._test_privateMediaAdmissionEntryCount() <= 512)
await service._test_drainPrivateMediaSendPipeline()
}
@Test
func privateMediaAdmissionCapacityRejectsNewcomerWithoutEvictingActiveTransfer() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-capacity-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let now = Date()
let activeIDs = (0..<512).map { "capacity-active-\($0)" }
for transferId in activeIDs {
#expect(service._test_beginPrivateMediaAdmission(transferId, now: now))
}
defer {
for transferId in activeIDs {
service._test_finishPrivateMediaAdmission(transferId)
}
}
let overflowID = "capacity-overflow-\(UUID().uuidString)"
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
let content = Data("%PDF-1.7\ncapacity".utf8)
service.sendFilePrivate(
BitchatFilePacket(
fileName: "capacity.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: PeerID(str: "1122334455667788"),
transferId: overflowID,
allowLegacyFallback: true
)
#expect(await TestHelpers.waitUntil(
{ rejections.contains(overflowID) },
timeout: TestConstants.longTimeout
))
#expect(rejections.reason(for: overflowID) != nil)
#expect(service._test_isPrivateMediaAdmissionActive(activeIDs[0], now: now))
#expect(service._test_privateMediaAdmissionEntryCount() == 512)
_ = cancellable
}
@Test
func expiredActivePrivateMediaAdmissionEmitsVisibleFailure() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-expiry-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let transferId = "expired-active-\(UUID().uuidString)"
let admittedAt = Date(timeIntervalSince1970: 1_000)
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
#expect(service._test_beginPrivateMediaAdmission(transferId, now: admittedAt))
#expect(!service._test_isPrivateMediaAdmissionActive(
transferId,
now: admittedAt.addingTimeInterval(60 * 60 + 1)
))
#expect(await TestHelpers.waitUntil(
{ rejections.contains(transferId) },
timeout: TestConstants.longTimeout
))
#expect(rejections.reason(for: transferId) != nil)
#expect(service._test_privateMediaAdmissionEntryCount() == 0)
_ = cancellable
}
@Test
func approvedLegacySendCancelledBeforeDeferredAdmissionDoesNotTransmit() async throws {
try await assertApprovedLegacySendCancelledBeforeAdmission(label: "cancel")
}
@Test
func approvedLegacySendDeletedBeforeDeferredAdmissionDoesNotTransmit() async throws {
// ChatMediaTransferCoordinator.deleteMediaMessage now invokes this same
// synchronous transport cancellation before removing its mapping; its
// coordinator-level call is covered separately in the context tests.
try await assertApprovedLegacySendCancelledBeforeAdmission(label: "delete")
}
@Test
func panicSuspensionFinishesAdmissionAtInitialDeferredSendBoundary() async {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-panic-deferred-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let tap = PacketTap()
service._test_onOutboundPacket = tap.record
service.suspendForPanicReset()
defer { service.completePanicReset(restartServices: false) }
let transferId = "panic-deferred-\(UUID().uuidString)"
let content = Data("%PDF-1.7\npanic-deferred".utf8)
service.sendFilePrivate(
BitchatFilePacket(
fileName: "panic-deferred.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: PeerID(str: "1122334455667788"),
transferId: transferId,
allowLegacyFallback: true
)
await service._test_drainPrivateMediaSendPipeline()
let state = service._test_privateMediaTransferState(transferId: transferId)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
#expect(state.activeScheduler == 0)
#expect(state.pendingScheduler == 0)
#expect(service._test_privateMediaAdmissionEntryCount() == 0)
#expect(tap.snapshot().isEmpty)
}
@Test
func panicSuspensionFinishesAdmissionAtBroadcastBoundary() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-panic-broadcast-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let service = makeService(baseDirectory: root)
let tap = PacketTap()
service._test_onOutboundPacket = tap.record
service.suspendForPanicReset()
defer { service.completePanicReset(restartServices: false) }
let transferId = "panic-broadcast-\(UUID().uuidString)"
#expect(service._test_beginPrivateMediaAdmission(transferId, now: Date()))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: Data(hexString: "1122334455667788"),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: Data([NoisePayloadType.privateFile.rawValue]),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
service._test_broadcastPrivateMediaPacket(packet, transferId: transferId)
#expect(!service._test_isPrivateMediaAdmissionActive(transferId, now: Date()))
#expect(service._test_privateMediaAdmissionEntryCount() == 0)
#expect(tap.snapshot().isEmpty)
}
@Test
func legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
let alice = makeService(baseDirectory: aliceRoot)
let bob = makeService(baseDirectory: bobRoot)
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Old Bob",
noisePublicKey: bob.noiseStaticPublicKeyData()
)
let tap = PacketTap()
let delegate = MessageCaptureDelegate()
alice._test_onOutboundPacket = tap.record
bob.delegate = delegate
let content = Data("%PDF-1.7\nprivate".utf8)
let file = BitchatFilePacket(
fileName: "private.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let cancellations = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) }
let deniedID = "legacy-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: deniedID
)
let denied = await TestHelpers.waitUntil(
{ cancellations.contains(deniedID) },
timeout: TestConstants.longTimeout
)
#expect(denied)
#expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
let allowedID = "legacy-with-consent-\(UUID().uuidString)"
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: allowedID,
allowLegacyFallback: true
)
let sent = await TestHelpers.waitUntil(
{ tap.snapshot().contains { $0.type == MessageType.fileTransfer.rawValue } },
timeout: TestConstants.longTimeout
)
#expect(sent)
let outbound = tap.snapshot()
let rawTransfers = outbound.filter { $0.type == MessageType.fileTransfer.rawValue }
let raw = try #require(rawTransfers.first)
#expect(rawTransfers.count == 1, "Migration fallback must never dual-send")
#expect(outbound.allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue })
#expect(raw.recipientID == Data(hexString: bob.myPeerID.toShort().id))
#expect(raw.signature?.count == 64)
#expect(BitchatFilePacket.decode(raw.payload)?.content == content)
// Exercise the normal raw receive path with Alice's actual signing
// key. The migration fallback is accepted because it is directed and
// signed; the handler still rejects unsigned/forged raw transfers.
bob._test_handlePacket(
raw,
fromPeerID: alice.myPeerID,
signingPublicKey: alice.noiseSigningPublicKeyData()
)
let delivered = await TestHelpers.waitUntil(
{ delegate.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
#expect(delivered)
#expect(delegate.snapshot().first?.isPrivate == true)
#expect(recursivelyStoredFiles(under: bobRoot).count == 1)
// Consent is invocation-scoped, not a sticky peer preference.
let retryID = "legacy-retry-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID)
let retryDenied = await TestHelpers.waitUntil(
{ cancellations.contains(retryID) },
timeout: TestConstants.longTimeout
)
#expect(retryDenied)
#expect(tap.snapshot().filter { $0.type == MessageType.fileTransfer.rawValue }.count == 1)
_ = cancellable
}
@Test
func authenticatedPrivateMediaCapabilityPinsAgainstRawDowngrade() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-pin-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Old Bob", capabilities: [])
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
let bobKey = bob.noiseStaticPublicKeyData()
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
let bobFingerprint = bobKey.sha256Fingerprint()
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
try establishSession(alice: alice, bob: bob)
let capabilityPinned = await TestHelpers.waitUntil(
{ identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) },
timeout: TestConstants.longTimeout
)
#expect(capabilityPinned)
// Model a later verified old/replayed announce for the same stable
// Noise identity. The current bit disappears, but the authenticated
// persistent pin wins.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: bobKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade)
let tap = PacketTap()
alice._test_onOutboundPacket = tap.record
let transferID = "unsupported-private-media-\(UUID().uuidString)"
let cancellation = TransferCancellationRecorder(transferID: transferID)
let cancellable = TransferProgressManager.shared.publisher.sink { cancellation.record($0) }
let content = Data("%PDF-1.7\nprivate".utf8)
let transferID = "pinned-downgrade-\(UUID().uuidString)"
let cancellations = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) }
let content = Data("%PDF-1.7\nblocked".utf8)
alice.sendFilePrivate(
BitchatFilePacket(
fileName: "private.pdf",
fileName: "blocked.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferID
transferId: transferID,
allowLegacyFallback: true
)
let blocked = await TestHelpers.waitUntil(
{ cancellations.contains(transferID) },
timeout: TestConstants.longTimeout
)
#expect(blocked)
#expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
_ = cancellable
}
@Test
func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-explicit-capabilities-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Modern Bob",
capabilities: [],
noisePublicKey: bob.noiseStaticPublicKeyData()
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-poisoning-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let bobFingerprint = bob.noiseStaticPublicKeyData().sha256Fingerprint()
let capableAnnounce = try signedAnnounce(
from: bob,
capabilities: .privateMedia
)
alice._test_handlePacket(
capableAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let advertised = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted },
timeout: TestConstants.longTimeout
)
#expect(advertised)
// The production signed-announce path ran, but with no authenticated
// session it must remain a no-op. Querying policy is side-effect free.
#expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint))
let noBitAnnounce = try signedAnnounce(
from: bob,
capabilities: []
)
alice._test_handlePacket(
noBitAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let remainedLegacyEligible = await TestHelpers.waitUntil(
{ alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent },
timeout: TestConstants.longTimeout
)
#expect(remainedLegacyEligible)
}
@Test
func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-key-mismatch-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let impostor = makeService(baseDirectory: root.appendingPathComponent("impostor", isDirectory: true))
let impostorKey = impostor.noiseStaticPublicKeyData()
let reconciliations = PeerIDRecorder()
alice._test_onPrivateMediaSessionReconciled = reconciliations.record
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: impostorKey
)
try establishSession(alice: alice, bob: bob)
let sessionReconciled = await TestHelpers.waitUntil(
{ reconciliations.contains(bob.myPeerID) },
timeout: TestConstants.longTimeout
)
#expect(sessionReconciled)
#expect(!identity.hasObservedPrivateMediaCapability(
fingerprint: impostorKey.sha256Fingerprint()
))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: [],
noisePublicKey: impostorKey
)
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let identity = MockIdentityManager(MockKeychain())
let alice = makeService(
baseDirectory: root.appendingPathComponent("alice", isDirectory: true),
identityManager: identity
)
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
let bobKey = bob.noiseStaticPublicKeyData()
let capableAnnounce = try signedAnnounce(
from: bob,
capabilities: .privateMedia
)
alice._test_handlePacket(
capableAnnounce,
fromPeerID: bob.myPeerID,
preseedPeer: false
)
let pinned = await TestHelpers.waitUntil(
{
identity.hasObservedPrivateMediaCapability(
fingerprint: bobKey.sha256Fingerprint()
)
},
timeout: TestConstants.longTimeout
)
#expect(pinned)
}
@Test
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
let oldCarol = makeService(baseDirectory: root.appendingPathComponent("carol", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
alice._test_seedConnectedPeer(
oldCarol.myPeerID,
nickname: "Old Carol",
noisePublicKey: oldCarol.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob)
var state: UInt64 = 0x1234_5678_9ABC_DEF0
let body = Data((0..<(130 * 1024)).map { _ in
state = state &* 6364136223846793005 &+ 1442695040888963407
return UInt8(truncatingIfNeeded: state >> 32)
})
let content = Data("%PDF-1.7\n".utf8) + body
let file = BitchatFilePacket(
fileName: "too-many-fragments.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let tap = PacketTap()
alice._test_onOutboundPacket = tap.record
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
let encryptedID = "encrypted-over-256-\(UUID().uuidString)"
let legacyID = "legacy-over-256-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID)
alice.sendFilePrivate(
file,
to: oldCarol.myPeerID,
transferId: legacyID,
allowLegacyFallback: true
)
let bothRejected = await TestHelpers.waitUntil(
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
timeout: TestConstants.longTimeout
)
#expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true)
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
_ = cancellable
}
@Test
func queuedPrivateEncryptionFailureRejectsBoundTransfer() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-queued-failure-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
try establishSession(alice: alice, bob: bob)
let transferID = "queued-encryption-failure-\(UUID().uuidString)"
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) }
var oversizedTypedPayload = Data([NoisePayloadType.privateFile.rawValue])
oversizedTypedPayload.append(Data(
repeating: 0x42,
count: NoiseSecurityConstants.maxPrivateFilePlaintextSize
))
alice._test_enqueuePendingNoisePayload(
oversizedTypedPayload,
transferId: transferID,
for: bob.myPeerID
)
alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID)
let rejected = await TestHelpers.waitUntil(
{ cancellation.wasCancelled },
{ rejections.contains(transferID) },
timeout: TestConstants.longTimeout
)
#expect(rejected)
let mediaWireTypes: Set<UInt8> = [
MessageType.noiseEncrypted.rawValue,
MessageType.fileTransfer.rawValue,
MessageType.fragment.rawValue
]
#expect(tap.snapshot().allSatisfy { !mediaWireTypes.contains($0.type) })
#expect(rejections.reason(for: transferID)?.isEmpty == false)
_ = cancellable
}
@Test
func canonical0x20EncryptedFileIsAcceptedAcrossV1OuterPacket() async throws {
let content = Data("%PDF-1.7\nandroid-private".utf8)
try await assertInboundEncryptedPrivateMedia(
typeByte: 0x20,
content: content,
outerVersion: 1,
directoryLabel: "android-0x20"
)
}
@Test
func prerelease0x09LargeEncryptedFileIsAcceptedDuringMigration() async throws {
let content = Data("%PDF-1.7\nprerelease-private".utf8)
+ Data(repeating: 0x39, count: 70 * 1024)
#expect(content.count > NoiseSecurityConstants.maxMessageSize)
try await assertInboundEncryptedPrivateMedia(
typeByte: NoisePayloadType.prereleasePrivateFileRawValue,
content: content,
outerVersion: 2,
directoryLabel: "prerelease-0x09"
)
}
@Test
func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws {
let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8)
@@ -84,11 +623,18 @@ struct PrivateMediaEndToEndTests {
}
@Test
func privatePDFIsOpaqueBeforeFragmentationAndDelivers() async throws {
func capablePeerUsesCanonicalAndroid0x20EncryptedSend() async throws {
let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8)
let content = Data("%PDF-1.7\n".utf8)
+ marker
+ Data(repeating: 0x50, count: 6 * 1024)
let file = BitchatFilePacket(
fileName: "private.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
#expect(BLENoisePayloadFactory.privateFile(file)?.first == 0x20)
try await assertPrivateMediaRoundTrip(
fileName: "private.pdf",
mimeType: "application/pdf",
@@ -114,6 +660,129 @@ struct PrivateMediaEndToEndTests {
)
}
/// Models an already-established remote sender independently of the local
/// send policy. Exact Android b7f0b33d plaintext bytes are frozen in
/// `BLENoisePayloadFactoryTests`; this helper exercises the encrypted
/// inbound transport around that shared wire encoding.
private func assertInboundEncryptedPrivateMedia(
typeByte: UInt8,
content: Data,
outerVersion: UInt8,
directoryLabel: String
) async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-\(directoryLabel)-\(UUID().uuidString)", isDirectory: true)
let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: aliceRoot)
let bob = makeService(baseDirectory: bobRoot)
let delegate = MessageCaptureDelegate()
bob.delegate = delegate
try establishSession(alice: alice, bob: bob)
let file = BitchatFilePacket(
fileName: "\(directoryLabel).pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let encodedFile = try #require(file.encode())
var typedPayload = Data([typeByte])
typedPayload.append(encodedFile)
let encrypted = try alice._test_makeEncryptedNoisePacket(typedPayload, to: bob.myPeerID)
let remoteShapedPacket = BitchatPacket(
type: encrypted.type,
senderID: encrypted.senderID,
recipientID: encrypted.recipientID,
timestamp: encrypted.timestamp,
payload: encrypted.payload,
signature: nil,
ttl: encrypted.ttl,
version: outerVersion
)
bob._test_handlePacket(remoteShapedPacket, fromPeerID: alice.myPeerID)
let delivered = await TestHelpers.waitUntil(
{ delegate.snapshot().count == 1 },
timeout: TestConstants.longTimeout
)
#expect(delivered)
#expect(delegate.snapshot().first?.isPrivate == true)
let stored = recursivelyStoredFiles(under: bobRoot)
#expect(stored.count == 1)
if let storedURL = stored.first {
#expect(try Data(contentsOf: storedURL) == content)
}
}
private func assertApprovedLegacySendCancelledBeforeAdmission(label: String) async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-admission-\(label)-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Legacy Bob",
noisePublicKey: bob.noiseStaticPublicKeyData()
)
let transferId = "approved-\(label)-\(UUID().uuidString)"
let gate = PrivateMediaDeferredSendGate()
let tap = PacketTap()
alice._test_onOutboundPacket = tap.record
alice._test_beforePrivateMediaDeferredSend = { id in
guard id == transferId else { return }
gate.pause()
}
defer {
gate.release()
alice._test_beforePrivateMediaDeferredSend = nil
}
let content = Data("%PDF-1.7\ncancelled-before-admission".utf8)
alice.sendFilePrivate(
BitchatFilePacket(
fileName: "cancelled.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferId,
allowLegacyFallback: true
)
let paused = await TestHelpers.waitUntil(
{ gate.hasPaused },
timeout: TestConstants.longTimeout
)
#expect(paused)
// This is the transport action used by both cancel and delete. It must
// invalidate synchronously while messageQueue is still held above.
alice.cancelTransfer(transferId)
gate.release()
await alice._test_drainPrivateMediaSendPipeline()
let state = alice._test_privateMediaTransferState(transferId: transferId)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
#expect(state.activeScheduler == 0)
#expect(state.pendingScheduler == 0)
#expect(await TestHelpers.waitUntil(
{ alice._test_privateMediaAdmissionEntryCount() == 0 },
timeout: TestConstants.longTimeout
))
#expect(tap.snapshot().allSatisfy {
$0.type != MessageType.fileTransfer.rawValue
&& $0.type != MessageType.noiseEncrypted.rawValue
})
}
private func assertPrivateMediaRoundTrip(
fileName: String,
mimeType: String,
@@ -135,8 +804,18 @@ struct PrivateMediaEndToEndTests {
alice._test_onOutboundPacket = tap.record
bob.delegate = delegate
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Bob", capabilities: .privateMedia)
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: .privateMedia,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: .privateMedia,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try establishSession(alice: alice, bob: bob)
let file = BitchatFilePacket(
@@ -197,12 +876,15 @@ struct PrivateMediaEndToEndTests {
#expect(try Data(contentsOf: storedURL) == content)
}
private func makeService(baseDirectory: URL) -> BLEService {
private func makeService(
baseDirectory: URL,
identityManager: SecureIdentityStateManagerProtocol? = nil
) -> BLEService {
let keychain = MockKeychain()
return BLEService(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: MockIdentityManager(keychain),
identityManager: identityManager ?? MockIdentityManager(keychain),
initializeBluetoothManagers: false,
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory)
)
@@ -221,6 +903,30 @@ struct PrivateMediaEndToEndTests {
#expect(bob.canDeliverSecurely(to: alice.myPeerID))
}
private func signedAnnounce(
from service: BLEService,
capabilities: PeerCapabilities?
) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: "Bob",
noisePublicKey: service.noiseStaticPublicKeyData(),
signingPublicKey: service.noiseSigningPublicKeyData(),
directNeighbors: nil,
capabilities: capabilities
)
let payload = try #require(announcement.encode())
let unsigned = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return service.signPacketForBroadcast(unsigned)
}
private func recursivelyStoredFiles(under root: URL) -> [URL] {
guard let enumerator = FileManager.default.enumerator(
at: root,
@@ -266,6 +972,52 @@ private final class PacketTap: @unchecked Sendable {
}
}
private final class PrivateMediaDeferredSendGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
private var released = false
var hasPaused: Bool {
condition.lock()
defer { condition.unlock() }
return paused
}
func pause() {
condition.lock()
paused = true
condition.broadcast()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
private final class PeerIDRecorder: @unchecked Sendable {
private let lock = NSLock()
private var peerIDs: [PeerID] = []
func record(_ peerID: PeerID) {
lock.lock()
peerIDs.append(peerID)
lock.unlock()
}
func contains(_ peerID: PeerID) -> Bool {
lock.lock()
defer { lock.unlock() }
return peerIDs.contains(peerID)
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var messages: [BitchatMessage] = []
@@ -289,24 +1041,38 @@ private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable
}
private final class TransferCancellationRecorder: @unchecked Sendable {
private let transferID: String
private let lock = NSLock()
private var cancelled = false
init(transferID: String) {
self.transferID = transferID
}
private var transferIDs: Set<String> = []
private var rejectionReasons: [String: String] = [:]
func record(_ event: TransferProgressManager.Event) {
guard case .cancelled(let id, _, _) = event, id == transferID else { return }
let id: String
switch event {
case .cancelled(let cancelledID, _, _):
id = cancelledID
case .rejected(let rejectedID, _):
id = rejectedID
case .started, .updated, .completed:
return
}
lock.lock()
cancelled = true
transferIDs.insert(id)
if case .rejected(_, let reason) = event {
rejectionReasons[id] = reason
}
lock.unlock()
}
var wasCancelled: Bool {
func contains(_ transferID: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
return transferIDs.contains(transferID)
}
func reason(for transferID: String) -> String? {
lock.lock()
defer { lock.unlock() }
return rejectionReasons[transferID]
}
}
+12 -1
View File
@@ -14,6 +14,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
init(_: KeychainManagerProtocol) {}
@@ -87,7 +88,9 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func clearAllIdentityData() {}
func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {}
@@ -101,6 +104,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
Set()
}
func markPrivateMediaCapable(fingerprint: String) {
privateMediaCapableFingerprints.insert(fingerprint)
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
privateMediaCapableFingerprints.contains(fingerprint)
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+17
View File
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
var peerNicknames: [PeerID: String] = [:]
var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation
@@ -186,6 +188,21 @@ final class MockTransport: Transport {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicies[peerID] ?? .encrypted
}
func cancelTransfer(_ transferId: String) {
@@ -1,5 +1,6 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct BLENoisePayloadFactoryTests {
@@ -44,10 +45,38 @@ struct BLENoisePayloadFactoryTests {
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
#expect(payload.first == NoisePayloadType.privateFile.rawValue)
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
#expect(decoded.fileName == "secret.pdf")
#expect(decoded.mimeType == "application/pdf")
#expect(decoded.content == content)
}
@Test
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
// Runtime-emitted by Android commit b7f0b33d from
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
let fixture = try #require(Data(hexString: fixtureHex))
let typed = try #require(NoisePayload.decode(fixture))
#expect(typed.type == .privateFile)
let file = try #require(BitchatFilePacket.decode(typed.data))
#expect(file.fileName == "a.txt")
#expect(file.fileSize == 3)
#expect(file.mimeType == "text/plain")
#expect(file.content == Data([0x01, 0x02, 0x03]))
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
}
@Test
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
let decoded = try #require(NoisePayload.decode(encoded))
#expect(decoded.type == .privateFile)
#expect(decoded.data == Data([0xCA, 0xFE]))
#expect(decoded.encode().first == 0x20)
}
}
@@ -73,7 +73,7 @@ struct BLENoiseSessionQueuesTests {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues()
queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID)
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
let removed = queues.removeTypedPayload(transferId: "media-1")
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
) == nil)
}
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
func privateMediaCrossPlatformFragmentBoundary() throws {
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: Data(hexString: "8877665544332211"),
timestamp: 0x0102030405,
payload: Data(maxPayload.prefix(payloadCount)),
signature: nil,
ttl: 3,
version: 2
)
return try #require(BLEOutboundFragmentPlanner.makePlan(
for: BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: PeerID(str: "8877665544332211"),
transferId: "boundary"
),
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xD4, count: 8)
))
}
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
var low = 1
var high = maxPayload.count
while low < high {
let mid = low + (high - low) / 2
if try plan(payloadCount: mid).totalFragments >= target {
high = mid
} else {
low = mid + 1
}
}
return try plan(payloadCount: low)
}
let at256 = try firstPlan(withAtLeast: 256)
let at257 = try firstPlan(withAtLeast: 257)
#expect(at256.totalFragments == 256)
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
#expect(at257.totalFragments == 257)
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
private func makePacket(
payload: Data,
route: [Data]? = nil,
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
}
@Test("registry preserves absent versus explicit empty capabilities")
func capabilitiesPresenceIsPreserved() {
var registry = BLEPeerRegistry()
let oldPeer = PeerID(str: "1122334455667788")
let modernPeer = PeerID(str: "8877665544332211")
_ = registry.upsertVerifiedAnnounce(
peerID: oldPeer,
nickname: "old",
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x12, count: 32),
isConnected: true,
now: Date(),
capabilities: nil
)
_ = registry.upsertVerifiedAnnounce(
peerID: modernPeer,
nickname: "modern",
noisePublicKey: Data(repeating: 0x21, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
isConnected: true,
now: Date(),
capabilities: []
)
#expect(registry.capabilities(for: oldPeer).isEmpty)
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
#expect(registry.capabilities(for: modernPeer).isEmpty)
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
}
@Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788")
@@ -399,6 +399,34 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertTrue(cleared)
}
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
let keychain = MockKeychain()
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
let manager = SecureIdentityStateManager(keychain)
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
manager.markPrivateMediaCapable(fingerprint: fingerprint)
XCTAssertTrue(
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
"pin insertion must be synchronously visible to the next downgrade decision"
)
// Re-marking is idempotent, and the encrypted cache carries the pin
// across launches.
manager.markPrivateMediaCapable(fingerprint: fingerprint)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
// ChatViewModel's panic path calls this same wipe after deleting
// keychain data; the in-memory pin must disappear immediately too.
reloaded.clearAllIdentityData()
let cleared = await waitUntil {
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain)
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
recorder.append("updated:\(id):\(sent):\(total)")
case .completed(let id, let total):
recorder.append("completed:\(id):\(total)")
case .cancelled:
case .cancelled, .rejected:
break
}
}
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
recorder.append("started:\(id):\(total)")
case .cancelled(let id, let sent, let total):
recorder.append("cancelled:\(id):\(sent):\(total)")
case .updated, .completed:
case .updated, .completed, .rejected:
break
}
}
@@ -106,24 +106,22 @@ struct TransferProgressManagerTests {
_ = cancellable
}
@Test("Preflight rejection publishes cancellation without a started state")
@Test("Preflight policy rejection publishes a visible failure reason")
@MainActor
func rejectBeforeStartPublishesCancellation() async {
func rejectBeforeStartPublishesReason() async {
let manager = TransferProgressManager()
let transferID = "transfer-preflight-reject"
var cancellable: AnyCancellable?
let transferID = "transfer-visible-reject"
let recorder = EventRecorder()
cancellable = manager.publisher.sink { event in
if case .cancelled(let id, let sent, let total) = event {
recorder.append("cancelled:\(id):\(sent):\(total)")
let cancellable = manager.publisher.sink { event in
if case .rejected(let id, let reason) = event {
recorder.append("rejected:\(id):\(reason)")
}
}
manager.rejectBeforeStart(id: transferID)
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
let didReceive = await TestHelpers.waitUntil({
recorder.values == ["cancelled:\(transferID):0:0"]
recorder.values == ["rejected:\(transferID):upgrade required"]
}, timeout: 5.0)
#expect(didReceive)
#expect(manager.snapshot(id: transferID) == nil)
@@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
verified.removeAll()
}
func markPrivateMediaCapable(fingerprint: String) {}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {