mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send. Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling. Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
148 lines
4.9 KiB
Swift
148 lines
4.9 KiB
Swift
import Foundation
|
|
import Combine
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
@Suite("TransferProgressManager Tests")
|
|
struct TransferProgressManagerTests {
|
|
|
|
@Test("Start publishes started event and stores snapshot")
|
|
@MainActor
|
|
func startPublishesAndStoresSnapshot() async throws {
|
|
let manager = TransferProgressManager()
|
|
let transferID = "transfer-start"
|
|
var cancellable: AnyCancellable?
|
|
let recorder = EventRecorder()
|
|
|
|
cancellable = manager.publisher.sink { event in
|
|
if case .started(let id, let total) = event {
|
|
recorder.append("started:\(id):\(total)")
|
|
}
|
|
}
|
|
|
|
manager.start(id: transferID, totalFragments: 3)
|
|
|
|
let didReceive = await TestHelpers.waitUntil({
|
|
recorder.values == ["started:\(transferID):3"]
|
|
}, timeout: 5.0)
|
|
#expect(didReceive)
|
|
|
|
#expect(recorder.values == ["started:\(transferID):3"])
|
|
#expect(manager.snapshot(id: transferID)?.sent == 0)
|
|
#expect(manager.snapshot(id: transferID)?.total == 3)
|
|
_ = cancellable
|
|
}
|
|
|
|
@Test("Sending final fragment publishes update and completion then clears snapshot")
|
|
@MainActor
|
|
func recordFragmentSentPublishesProgressAndCompletion() async throws {
|
|
let manager = TransferProgressManager()
|
|
let transferID = "transfer-complete"
|
|
var cancellable: AnyCancellable?
|
|
let recorder = EventRecorder()
|
|
|
|
cancellable = manager.publisher.sink { event in
|
|
switch event {
|
|
case .started(let id, let total):
|
|
recorder.append("started:\(id):\(total)")
|
|
case .updated(let id, let sent, let total):
|
|
recorder.append("updated:\(id):\(sent):\(total)")
|
|
case .completed(let id, let total):
|
|
recorder.append("completed:\(id):\(total)")
|
|
case .cancelled, .rejected:
|
|
break
|
|
}
|
|
}
|
|
|
|
manager.start(id: transferID, totalFragments: 1)
|
|
manager.recordFragmentSent(id: transferID)
|
|
|
|
let didReceive = await TestHelpers.waitUntil({
|
|
recorder.values.count == 3
|
|
}, timeout: 5.0)
|
|
#expect(didReceive)
|
|
|
|
#expect(recorder.values == [
|
|
"started:\(transferID):1",
|
|
"updated:\(transferID):1:1",
|
|
"completed:\(transferID):1"
|
|
])
|
|
#expect(manager.snapshot(id: transferID) == nil)
|
|
_ = cancellable
|
|
}
|
|
|
|
@Test("Cancel publishes cancelled event and clears state")
|
|
@MainActor
|
|
func cancelPublishesAndClearsState() async throws {
|
|
let manager = TransferProgressManager()
|
|
let transferID = "transfer-cancel"
|
|
var cancellable: AnyCancellable?
|
|
let recorder = EventRecorder()
|
|
|
|
cancellable = manager.publisher.sink { event in
|
|
switch event {
|
|
case .started(let id, let total):
|
|
recorder.append("started:\(id):\(total)")
|
|
case .cancelled(let id, let sent, let total):
|
|
recorder.append("cancelled:\(id):\(sent):\(total)")
|
|
case .updated, .completed, .rejected:
|
|
break
|
|
}
|
|
}
|
|
|
|
manager.start(id: transferID, totalFragments: 4)
|
|
manager.recordFragmentSent(id: transferID)
|
|
manager.cancel(id: transferID)
|
|
|
|
let didReceive = await TestHelpers.waitUntil({
|
|
recorder.values.contains("started:\(transferID):4") &&
|
|
recorder.values.contains("cancelled:\(transferID):1:4")
|
|
}, timeout: 5.0)
|
|
#expect(didReceive)
|
|
|
|
#expect(recorder.values.contains("started:\(transferID):4"))
|
|
#expect(recorder.values.contains("cancelled:\(transferID):1:4"))
|
|
#expect(manager.snapshot(id: transferID) == nil)
|
|
_ = cancellable
|
|
}
|
|
|
|
@Test("Preflight policy rejection publishes a visible failure reason")
|
|
@MainActor
|
|
func rejectBeforeStartPublishesReason() async {
|
|
let manager = TransferProgressManager()
|
|
let transferID = "transfer-visible-reject"
|
|
let recorder = EventRecorder()
|
|
let cancellable = manager.publisher.sink { event in
|
|
if case .rejected(let id, let reason) = event {
|
|
recorder.append("rejected:\(id):\(reason)")
|
|
}
|
|
}
|
|
|
|
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
|
|
|
let didReceive = await TestHelpers.waitUntil({
|
|
recorder.values == ["rejected:\(transferID):upgrade required"]
|
|
}, timeout: 5.0)
|
|
#expect(didReceive)
|
|
#expect(manager.snapshot(id: transferID) == nil)
|
|
_ = cancellable
|
|
}
|
|
}
|
|
|
|
private final class EventRecorder: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var storage: [String] = []
|
|
|
|
var values: [String] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return storage
|
|
}
|
|
|
|
func append(_ value: String) {
|
|
lock.lock()
|
|
storage.append(value)
|
|
lock.unlock()
|
|
}
|
|
}
|