mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 01:05:20 +00:00
Make private-media deletion transactional (#1468)
Receiver-side private-media deletion (per-bubble delete and /clear) becomes a write-ahead transaction: a deletion journal in BLEPrivateMediaReceiptStore is the atomic commit point, materialization (tombstones + payload unlinks) is idempotent and retried on recovery, path reservations in BLEIncomingFileStore prevent delete racing an in-flight arrival, and overlapping /clear operations are serialized with panic-generation invalidation. Integrates with the receipt quarantine (a pending journal entry outranks quarantine; materialization never resurrects a quarantined ID). Review fixes included: (1) /clear no longer deletes outgoing media mirrored into another conversation (alias protection now mirrors the incoming path, with a regression test that fails pre-fix); (2) explicit delete of legacy incoming media actually unlinks the decrypted payload when unreferenced — gated on pending-delivery/reservation state, restoring main's delete semantics safely instead of leaving plaintext for quota cleanup; (3) refused deletions surface a localized system message in the affected chat instead of failing silently (30-locale key). Full local suite 1876 green.
This commit is contained in:
@@ -14,7 +14,7 @@ import BitFoundation
|
||||
|
||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||
/// Records all method calls and allows test code to verify interactions.
|
||||
final class MockTransport: Transport {
|
||||
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||
|
||||
// MARK: - Protocol Properties
|
||||
|
||||
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
|
||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = []
|
||||
private(set) var deletedPrivateMediaRelativePaths: [
|
||||
[String: String]
|
||||
] = []
|
||||
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||
@@ -60,6 +65,11 @@ final class MockTransport: Transport {
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
var persistDeletedPrivateMediaResult = true
|
||||
var deferDeletedPrivateMediaPersistence = false
|
||||
private var pendingDeletedPrivateMediaCompletions: [
|
||||
@MainActor (Bool) -> Void
|
||||
] = []
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -217,6 +227,46 @@ final class MockTransport: Transport {
|
||||
cancelledTransfers.append(transferId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func persistDeletedPrivateMedia(
|
||||
messageIDs: [String],
|
||||
payloadRelativePaths: [String: String],
|
||||
protectedPayloadRelativePaths: Set<String>,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
deletedPrivateMediaMessageIDBatches.append(messageIDs)
|
||||
deletedPrivateMediaRelativePaths.append(payloadRelativePaths)
|
||||
protectedPrivateMediaRelativePaths.append(
|
||||
protectedPayloadRelativePaths
|
||||
)
|
||||
if deferDeletedPrivateMediaPersistence {
|
||||
pendingDeletedPrivateMediaCompletions.append(completion)
|
||||
} else {
|
||||
completion(persistDeletedPrivateMediaResult)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolveNextDeletedPrivateMediaPersistence(
|
||||
_ result: Bool? = nil
|
||||
) {
|
||||
guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return }
|
||||
let completion = pendingDeletedPrivateMediaCompletions.removeFirst()
|
||||
completion(result ?? persistDeletedPrivateMediaResult)
|
||||
}
|
||||
|
||||
/// Real store instance so view-model tests exercise the gated legacy
|
||||
/// unlink end to end (same Application Support tree the tests write to).
|
||||
let legacyIncomingFileStore = BLEIncomingFileStore()
|
||||
private(set) var removedLegacyPrivateMediaPaths: [String] = []
|
||||
|
||||
func removeLegacyPrivateMediaPayload(relativePath: String) {
|
||||
removedLegacyPrivateMediaPaths.append(relativePath)
|
||||
legacyIncomingFileStore.removeLegacyIncomingFile(
|
||||
relativePath: relativePath
|
||||
)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
@@ -264,6 +314,9 @@ final class MockTransport: Transport {
|
||||
sentBroadcastFiles.removeAll()
|
||||
sentPrivateFiles.removeAll()
|
||||
cancelledTransfers.removeAll()
|
||||
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||
deletedPrivateMediaRelativePaths.removeAll()
|
||||
protectedPrivateMediaRelativePaths.removeAll()
|
||||
sentVerifyChallenges.removeAll()
|
||||
sentVerifyResponses.removeAll()
|
||||
startServicesCallCount = 0
|
||||
|
||||
Reference in New Issue
Block a user