mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:05:19 +00:00
Harden private media migration compatibility
This commit is contained in:
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]] = [:]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user