mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:05:19 +00:00
Make private media deletion transactional
This commit is contained in:
@@ -58,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
|
||||
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
||||
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
||||
private(set) var untombstonedMediaRemovals: [String] = []
|
||||
private(set) var outgoingMediaRemovals: [String] = []
|
||||
private(set) var systemMessages: [String] = []
|
||||
private(set) var notifyUIChangedCount = 0
|
||||
|
||||
@@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
removedMessages.append((messageID, cleanupFile))
|
||||
}
|
||||
|
||||
func removeUntombstonedMediaMessage(withID messageID: String) {
|
||||
untombstonedMediaRemovals.append(messageID)
|
||||
removedMessages.append((messageID, false))
|
||||
}
|
||||
|
||||
func removeOutgoingMediaMessage(withID messageID: String) {
|
||||
outgoingMediaRemovals.append(messageID)
|
||||
removedMessages.append((messageID, false))
|
||||
}
|
||||
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
||||
|
||||
@@ -99,6 +111,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
||||
private(set) var persistedDeletionBatches: [[String]] = []
|
||||
var requiredTombstoneIDs: Set<String> = []
|
||||
var deletedMediaPersistenceResult = true
|
||||
var deferDeletedMediaPersistence = false
|
||||
private var pendingDeletionCompletions: [
|
||||
@MainActor (Bool) -> Void
|
||||
] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
var resolvesPrivateMediaPolicyImmediately = true
|
||||
@@ -218,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
cancelledTransfers.append(transferId)
|
||||
}
|
||||
|
||||
func persistDeletedPrivateMedia(
|
||||
messageIDs: [String],
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
persistedDeletionBatches.append(messageIDs)
|
||||
if deferDeletedMediaPersistence {
|
||||
pendingDeletionCompletions.append(completion)
|
||||
} else {
|
||||
completion(deletedMediaPersistenceResult)
|
||||
}
|
||||
}
|
||||
|
||||
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
|
||||
requiredTombstoneIDs.contains(messageID)
|
||||
}
|
||||
|
||||
func resolveNextDeletionPersistence(_ result: Bool? = nil) {
|
||||
guard !pendingDeletionCompletions.isEmpty else { return }
|
||||
let completion = pendingDeletionCompletions.removeFirst()
|
||||
completion(result ?? deletedMediaPersistenceResult)
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
@@ -381,7 +422,8 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
|
||||
#expect(context.removedMessages.count == 1)
|
||||
#expect(context.removedMessages.first?.messageID == "m2")
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||
#expect(context.outgoingMediaRemovals == ["m2"])
|
||||
|
||||
// A pre-start rejection keeps the placeholder visible and failed,
|
||||
// including queued post-handshake encryption failures.
|
||||
@@ -531,7 +573,154 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||
#expect(
|
||||
context.untombstonedMediaRemovals == ["message-delete"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteIncomingStableMediaWaitsForDurableTombstone() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
context.requiredTombstoneIDs = [messageID]
|
||||
context.deferDeletedMediaPersistence = true
|
||||
coordinator.registerTransfer(
|
||||
transferId: "incoming-delete",
|
||||
messageID: messageID
|
||||
)
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: messageID)
|
||||
|
||||
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(context.cancelledTransfers == ["incoming-delete"])
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
|
||||
context.resolveNextDeletionPersistence(true)
|
||||
|
||||
#expect(context.cancelledTransfers == ["incoming-delete"])
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||
#expect(context.untombstonedMediaRemovals.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let messageID = "media-ffeeddccbbaa99887766554433221100"
|
||||
context.requiredTombstoneIDs = [messageID]
|
||||
context.deletedMediaPersistenceResult = false
|
||||
coordinator.registerTransfer(
|
||||
transferId: "failed-delete",
|
||||
messageID: messageID
|
||||
)
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: messageID)
|
||||
|
||||
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(context.cancelledTransfers == ["failed-delete"])
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
|
||||
async throws
|
||||
{
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.deferDeletedMediaPersistence = true
|
||||
let fileName = "voice_deadbeefdeadbeef.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
context.privateFileSends.first?.transferId
|
||||
)
|
||||
context.requiredTombstoneIDs = [messageID]
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: messageID)
|
||||
|
||||
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(context.cancelledTransfers == [transferID])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
|
||||
context.resolveNextDeletionPersistence(true)
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let incoming = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
let directoryName = "cleanup-dir-\(UUID().uuidString)"
|
||||
let directory = incoming.appendingPathComponent(
|
||||
directoryName,
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let child = directory.appendingPathComponent("child.jpg")
|
||||
try Data([0x01]).write(to: child)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let message = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: "Peer",
|
||||
content:
|
||||
"\(MimeType.Category.image.messagePrefix)\(directoryName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Me"
|
||||
)
|
||||
|
||||
coordinator.cleanupIncomingLocalFile(forMessage: message)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -1194,6 +1194,697 @@ struct ChatViewModelBluetoothTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Media Deletion Tests
|
||||
|
||||
struct ChatViewModelPrivateMediaDeletionTests {
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "8", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "e", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "f", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: outgoingID)
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: incomingID)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||
)
|
||||
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func stableDeleteProtectsPathSharedWithLegacyBubble() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "6", count: 64))
|
||||
let stableID = "media-\(String(repeating: "5", count: 32))"
|
||||
let legacyID = UUID().uuidString
|
||||
let filename = "shared-migration.jpg"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: legacyID,
|
||||
sender: "Old client",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: stableID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
|
||||
)
|
||||
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
|
||||
#expect(
|
||||
transport.protectedPrivateMediaRelativePaths == [[
|
||||
"images/incoming/\(filename)"
|
||||
]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [stableID, legacyID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||
let incoming = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incoming,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = incoming.appendingPathComponent(
|
||||
"legacy-delete-\(UUID().uuidString).jpg"
|
||||
)
|
||||
try Data([0x01]).write(to: payload)
|
||||
defer { try? FileManager.default.removeItem(at: payload) }
|
||||
let legacyID = UUID().uuidString
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: legacyID,
|
||||
sender: "Old client",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: payload.lastPathComponent
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.deleteMediaMessage(messageID: legacyID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "1", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "a", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "b", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "ordinary-message",
|
||||
sender: "Peer",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
viewModel.registerTransfer(
|
||||
transferId: "outgoing-clear-transfer",
|
||||
messageID: outgoingID
|
||||
)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||
)
|
||||
#expect(
|
||||
transport.cancelledTransfers == ["outgoing-clear-transfer"]
|
||||
)
|
||||
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "2", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "c", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "7", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: "outgoing.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "ordinary-message",
|
||||
sender: "Peer",
|
||||
content: "keep me on failure",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
viewModel.registerTransfer(
|
||||
transferId: "failed-clear-outgoing",
|
||||
messageID: outgoingID
|
||||
)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [incomingID, outgoingID, "ordinary-message"]
|
||||
)
|
||||
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
|
||||
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFailurePreservesSameNameIncomingPayload() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.persistDeletedPrivateMediaResult = false
|
||||
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "8", count: 32))"
|
||||
let outgoingID = "media-\(String(repeating: "9", count: 32))"
|
||||
let filename = "clear-collision-\(UUID().uuidString).jpg"
|
||||
let filesDirectory = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent("files/images", isDirectory: true)
|
||||
let incomingDirectory = filesDirectory.appendingPathComponent(
|
||||
"incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
let outgoingDirectory = filesDirectory.appendingPathComponent(
|
||||
"outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incomingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let incomingURL = incomingDirectory.appendingPathComponent(filename)
|
||||
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
|
||||
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
|
||||
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: incomingURL)
|
||||
try? FileManager.default.removeItem(at: outgoingURL)
|
||||
}
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: filename
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: outgoingID,
|
||||
sender: viewModel.nickname,
|
||||
senderPeerID: transport.myPeerID,
|
||||
recipient: "Peer",
|
||||
filename: filename
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
|
||||
#expect(
|
||||
transport.deletedPrivateMediaRelativePaths
|
||||
== [[incomingID: "images/incoming/\(filename)"]]
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [incomingID, outgoingID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||
let incomingID = "media-\(String(repeating: "d", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: incomingID,
|
||||
sender: "Peer",
|
||||
senderPeerID: peerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "incoming.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-text",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
], for: peerID)
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
let arrival = BitchatMessage(
|
||||
id: "concurrent-arrival",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== ["concurrent-arrival"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
let burstID = Data(
|
||||
repeating: 0xE1,
|
||||
count: VoiceBurstPacket.burstIDSize
|
||||
)
|
||||
let start = try #require(VoiceBurstPacket(
|
||||
burstID: burstID,
|
||||
seq: 0,
|
||||
kind: .start(codec: .aacLC16kMono)
|
||||
))
|
||||
let cancel = try #require(VoiceBurstPacket(
|
||||
burstID: burstID,
|
||||
seq: 1,
|
||||
kind: .canceled
|
||||
))
|
||||
let coordinator = viewModel.liveVoiceCoordinator
|
||||
defer {
|
||||
coordinator.handleVoiceFramePayload(
|
||||
from: peerID,
|
||||
payload: cancel.encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
coordinator.handleVoiceFramePayload(
|
||||
from: peerID,
|
||||
payload: start.encode(),
|
||||
timestamp: Date()
|
||||
)
|
||||
let liveMessage = try #require(
|
||||
viewModel.privateChats[peerID]?.first
|
||||
)
|
||||
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||
let ordinary = BitchatMessage(
|
||||
id: "clear-around-live-voice",
|
||||
sender: "Peer",
|
||||
content: "old text",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
|
||||
|
||||
viewModel.clearPrivateChat(peerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(
|
||||
viewModel.privateChats[peerID]?.map(\.id)
|
||||
== [liveMessage.id]
|
||||
)
|
||||
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
|
||||
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
|
||||
let sharedID = "media-\(String(repeating: "1", count: 32))"
|
||||
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
|
||||
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: sharedID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "shared.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: firstUniqueID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "first.jpg"
|
||||
)
|
||||
], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: sharedID,
|
||||
sender: "Peer",
|
||||
senderPeerID: secondPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "shared.jpg"
|
||||
),
|
||||
privateMediaMessage(
|
||||
id: secondUniqueID,
|
||||
sender: "Peer",
|
||||
senderPeerID: secondPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "second.jpg"
|
||||
)
|
||||
], for: secondPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
viewModel.clearPrivateChat(secondPeerID)
|
||||
let queuedArrival = BitchatMessage(
|
||||
id: "arrival-after-queued-clear",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: secondPeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
queuedArrival,
|
||||
to: secondPeerID
|
||||
))
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches
|
||||
== [[firstUniqueID]]
|
||||
)
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
transport.deletedPrivateMediaMessageIDBatches == [
|
||||
[firstUniqueID],
|
||||
[secondUniqueID, sharedID].sorted()
|
||||
]
|
||||
)
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
|
||||
#expect(
|
||||
viewModel.privateChats[secondPeerID]?.map(\.id)
|
||||
== ["arrival-after-queued-clear"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
|
||||
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
|
||||
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
|
||||
let stableID = "media-\(String(repeating: "4", count: 32))"
|
||||
viewModel.selectedPrivateChatPeer = sourcePeerID
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: sourcePeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "migrated.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-before-migration",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
], for: sourcePeerID)
|
||||
|
||||
viewModel.clearPrivateChat(sourcePeerID)
|
||||
viewModel.migratePrivateChat(
|
||||
from: sourcePeerID,
|
||||
to: destinationPeerID
|
||||
)
|
||||
viewModel.selectedPrivateChatPeer = thirdPeerID
|
||||
let arrival = BitchatMessage(
|
||||
id: "arrival-after-migration",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: destinationPeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
arrival,
|
||||
to: destinationPeerID
|
||||
))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
viewModel.privateChats[destinationPeerID]?.map(\.id)
|
||||
== ["arrival-after-migration"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearFollowsMigrationWhenOldSourceIsRecreated() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
transport.deferDeletedPrivateMediaPersistence = true
|
||||
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
|
||||
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
|
||||
let stableID = "media-\(String(repeating: "6", count: 32))"
|
||||
viewModel.seedPrivateChat([
|
||||
privateMediaMessage(
|
||||
id: stableID,
|
||||
sender: "Peer",
|
||||
senderPeerID: sourcePeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "migrated-recreated.jpg"
|
||||
),
|
||||
BitchatMessage(
|
||||
id: "captured-before-recreation",
|
||||
sender: "Peer",
|
||||
content: "old",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
], for: sourcePeerID)
|
||||
|
||||
viewModel.clearPrivateChat(sourcePeerID)
|
||||
viewModel.migratePrivateChat(
|
||||
from: sourcePeerID,
|
||||
to: destinationPeerID
|
||||
)
|
||||
let recreatedArrival = BitchatMessage(
|
||||
id: "arrival-recreating-source",
|
||||
sender: "Peer",
|
||||
content: "new",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: sourcePeerID
|
||||
)
|
||||
#expect(viewModel.appendPrivateMessage(
|
||||
recreatedArrival,
|
||||
to: sourcePeerID
|
||||
))
|
||||
|
||||
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||
|
||||
#expect(
|
||||
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateChats[sourcePeerID]?.map(\.id)
|
||||
== ["arrival-recreating-source"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||
let messageID = "media-\(String(repeating: "6", count: 32))"
|
||||
let message = privateMediaMessage(
|
||||
id: messageID,
|
||||
sender: "Peer",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: "mirrored.jpg"
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
|
||||
#expect(
|
||||
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
|
||||
throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
|
||||
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
|
||||
let incomingDirectory = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
.appendingPathComponent("files/images/incoming", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: incomingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let fileURL = incomingDirectory.appendingPathComponent(
|
||||
"legacy-clear-\(UUID().uuidString).jpg"
|
||||
)
|
||||
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
let message = privateMediaMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: "Old client",
|
||||
senderPeerID: firstPeerID,
|
||||
recipient: viewModel.nickname,
|
||||
filename: fileURL.lastPathComponent
|
||||
)
|
||||
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||
|
||||
viewModel.clearPrivateChat(firstPeerID)
|
||||
|
||||
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
|
||||
|
||||
viewModel.clearPrivateChat(aliasPeerID)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||
}
|
||||
|
||||
private func privateMediaMessage(
|
||||
id: String,
|
||||
sender: String,
|
||||
senderPeerID: PeerID,
|
||||
recipient: String,
|
||||
filename: String
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: sender,
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: recipient,
|
||||
senderPeerID: senderPeerID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panic Clear Tests
|
||||
|
||||
struct ChatViewModelPanicTests {
|
||||
|
||||
@@ -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,34 @@ 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)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
@@ -264,6 +302,9 @@ final class MockTransport: Transport {
|
||||
sentBroadcastFiles.removeAll()
|
||||
sentPrivateFiles.removeAll()
|
||||
cancelledTransfers.removeAll()
|
||||
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||
deletedPrivateMediaRelativePaths.removeAll()
|
||||
protectedPrivateMediaRelativePaths.removeAll()
|
||||
sentVerifyChallenges.removeAll()
|
||||
sentVerifyResponses.removeAll()
|
||||
startServicesCallCount = 0
|
||||
|
||||
@@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests {
|
||||
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||
var receiptCommitSucceeds = true
|
||||
var removedIncomingFiles: [URL] = []
|
||||
var finishedIncomingFileDeliveries: [URL] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
var deliveredMessages: [BitchatMessage] = []
|
||||
var shouldAcceptDelivery = true
|
||||
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
|
||||
var saveOverride: ((
|
||||
_ data: Data,
|
||||
_ preferredName: String?,
|
||||
@@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests {
|
||||
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryGateFinalizesInitialRejection() {
|
||||
var completions = 0
|
||||
var finalizations = 0
|
||||
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: { false },
|
||||
deliver: {
|
||||
Issue.record("delivery sink must not run")
|
||||
return .accepted
|
||||
},
|
||||
completion: { completions += 1 },
|
||||
finalization: { _ in finalizations += 1 }
|
||||
)
|
||||
|
||||
#expect(completions == 0)
|
||||
#expect(finalizations == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryGateFinalizesMissingOrRejectingSink() {
|
||||
var completions = 0
|
||||
var finalizations = 0
|
||||
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: { true },
|
||||
deliver: { .rejected },
|
||||
completion: { completions += 1 },
|
||||
finalization: { _ in finalizations += 1 }
|
||||
)
|
||||
|
||||
#expect(completions == 0)
|
||||
#expect(finalizations == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryGateFinalizesPostInsertionRejection() {
|
||||
var deliveryChecks = 0
|
||||
var completions = 0
|
||||
var finalizations = 0
|
||||
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: {
|
||||
deliveryChecks += 1
|
||||
return deliveryChecks == 1
|
||||
},
|
||||
deliver: { .accepted },
|
||||
completion: { completions += 1 },
|
||||
finalization: { _ in finalizations += 1 }
|
||||
)
|
||||
|
||||
#expect(deliveryChecks == 2)
|
||||
#expect(completions == 0)
|
||||
#expect(finalizations == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() {
|
||||
var completions = 0
|
||||
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: { true },
|
||||
deliver: { .invokedUnconfirmed },
|
||||
completion: { completions += 1 },
|
||||
finalization: { outcomes.append($0) }
|
||||
)
|
||||
|
||||
#expect(completions == 0)
|
||||
#expect(outcomes == [.invokedUnconfirmed])
|
||||
}
|
||||
|
||||
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
||||
let environment = BLEFileTransferHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
@@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.removedIncomingFiles.append(storedURL)
|
||||
recorder.removeIncomingFileOverride?(storedURL)
|
||||
},
|
||||
finishIncomingFileDelivery: { storedURL in
|
||||
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
||||
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { peerID in
|
||||
recorder.blockedPeers.contains(peerID)
|
||||
},
|
||||
@@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests {
|
||||
acknowledgePrivateMedia: { messageID, peerID in
|
||||
recorder.deliveryAcks.append((messageID, peerID))
|
||||
},
|
||||
deliverMessage: { message, shouldDeliver, completion in
|
||||
deliverMessage: { message, shouldDeliver, completion, finalization in
|
||||
var outcome = TransportEventDeliveryOutcome.rejected
|
||||
defer { finalization(outcome) }
|
||||
guard recorder.shouldAcceptDelivery else { return }
|
||||
guard shouldDeliver() else { return }
|
||||
recorder.deliveredMessages.append(message)
|
||||
guard shouldDeliver() else { return }
|
||||
if recorder.deliveryOutcome == .invokedUnconfirmed {
|
||||
outcome = .invokedUnconfirmed
|
||||
return
|
||||
}
|
||||
outcome = .accepted
|
||||
completion()
|
||||
}
|
||||
)
|
||||
@@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-handler-rejected-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.shouldAcceptDelivery = false
|
||||
recorder.saveOverride = {
|
||||
store.save(
|
||||
data: $0,
|
||||
preferredName: $1,
|
||||
subdirectory: $2,
|
||||
fallbackExtension: $3,
|
||||
defaultPrefix: $4
|
||||
)
|
||||
}
|
||||
recorder.receiptStateOverride = {
|
||||
store.privateMediaReceiptState(messageID: $0)
|
||||
}
|
||||
recorder.receiptCommitOverride = {
|
||||
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||
}
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
recorder.finishIncomingFileDeliveryOverride = {
|
||||
store.finishIncomingFileDelivery(at: $0)
|
||||
}
|
||||
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let fileName =
|
||||
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let stableID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||
#expect(store.reservePrivateMediaDeletion(
|
||||
messageIDs: [stableID],
|
||||
payloadRelativePaths: [:]
|
||||
) != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||
let recorder = Recorder()
|
||||
@@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.shouldAcceptDelivery = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "raw-rejected.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.removedIncomingFiles.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
recorder.deliveryOutcome = .invokedUnconfirmed
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "plain-delegate.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.removedIncomingFiles.isEmpty)
|
||||
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||
#expect(
|
||||
recorder.finishedIncomingFileDeliveries.first
|
||||
== recorder.saveResult
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||
let recorder = Recorder()
|
||||
@@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests {
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.saveOverride = {
|
||||
data,
|
||||
preferredName,
|
||||
subdirectory,
|
||||
fallbackExtension,
|
||||
defaultPrefix in
|
||||
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||
store.save(
|
||||
data: data,
|
||||
preferredName: preferredName,
|
||||
@@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
recorder.finishIncomingFileDeliveryOverride = {
|
||||
store.finishIncomingFileDelivery(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
let first = Recorder()
|
||||
@@ -880,6 +1091,21 @@ struct BLEFileTransferHandlerTests {
|
||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||
let payload = base
|
||||
.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent("panic-receipt.jpg")
|
||||
try FileManager.default.createDirectory(
|
||||
at: payload.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("secret".utf8).write(to: payload, options: .atomic)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
|
||||
@@ -4,8 +4,22 @@ import Testing
|
||||
|
||||
struct BLEPrivateMediaReceiptStoreTests {
|
||||
private struct TestError: Error {}
|
||||
private struct ReceiptFixture: Codable {
|
||||
let kind: String
|
||||
let relativePath: String?
|
||||
let recordedAt: Date
|
||||
}
|
||||
private struct JournalEntryFixture: Codable {
|
||||
let relativePaths: [String]
|
||||
let recordedAt: Date
|
||||
}
|
||||
private struct JournalFixture: Codable {
|
||||
let version: Int
|
||||
let entries: [String: JournalEntryFixture]
|
||||
}
|
||||
|
||||
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
|
||||
|
||||
@Test
|
||||
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||
@@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
|
||||
let root = makeRoot("accepted-ownership")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = try makePayload(in: root)
|
||||
let outgoingDirectory = root.appendingPathComponent(
|
||||
"files/images/outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
|
||||
try Data([0x01]).write(to: outgoing)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
|
||||
#expect(!store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: outgoing
|
||||
))
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: incoming
|
||||
))
|
||||
#expect(!store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: incoming
|
||||
))
|
||||
#expect(FileManager.default.fileExists(atPath: incoming.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoing.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
|
||||
let root = makeRoot("accepted-retention")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let recordedAt = Date(timeIntervalSince1970: 2_000)
|
||||
let seed = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
capacity: 1,
|
||||
ttl: 1,
|
||||
now: { recordedAt }
|
||||
)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
capacity: 1,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||
#expect(!relaunched.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
|
||||
let root = makeRoot("accepted-reservation")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
try FileManager.default.removeItem(at: original)
|
||||
|
||||
let stored = BLEIncomingFileStore(baseDirectory: root).save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
)
|
||||
|
||||
#expect(stored?.lastPathComponent != "image.jpg")
|
||||
#expect(stored.map {
|
||||
FileManager.default.fileExists(atPath: $0.path)
|
||||
} == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
|
||||
let root = makeRoot("pending-raw-arrival")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = BLEIncomingFileStore(baseDirectory: root)
|
||||
|
||||
// The old stable bubble names image.jpg, but its receipt and payload
|
||||
// have already been pruned. A raw arrival saves that basename before
|
||||
// its main-actor bubble is inserted.
|
||||
let rawArrival = incoming.save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
)
|
||||
#expect(rawArrival?.lastPathComponent == "image.jpg")
|
||||
|
||||
let reservation = incoming.reservePrivateMediaDeletion(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
)
|
||||
#expect(reservation == nil)
|
||||
#expect(rawArrival.map {
|
||||
FileManager.default.fileExists(atPath: $0.path)
|
||||
} == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
|
||||
let root = makeRoot("pending-quota")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let incoming = BLEIncomingFileStore(
|
||||
baseDirectory: root,
|
||||
quotaBytes: 1
|
||||
)
|
||||
let first = try #require(incoming.save(
|
||||
data: Data([0x01, 0x02]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
|
||||
incoming.enforceQuota(reservingBytes: 1)
|
||||
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||
|
||||
let second = try #require(incoming.save(
|
||||
data: Data([0x03]),
|
||||
preferredName: "image.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
#expect(second != first)
|
||||
#expect(second.lastPathComponent == "image (1).jpg")
|
||||
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||
#expect(FileManager.default.fileExists(atPath: second.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
|
||||
let root = makeRoot("invalid-owned-path")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let outgoingDirectory = root.appendingPathComponent(
|
||||
"files/images/outgoing",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: outgoingDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
|
||||
try Data([0x02]).write(to: victim)
|
||||
let receiptURL = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let fixture = ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/outgoing/victim.jpg",
|
||||
recordedAt: Date()
|
||||
)
|
||||
try JSONEncoder().encode(fixture).write(
|
||||
to: receiptURL,
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
#expect(
|
||||
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
.state(for: messageID) == .unavailable
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||
|
||||
try FileManager.default.removeItem(at: receiptURL)
|
||||
let journal = JournalFixture(
|
||||
version: 1,
|
||||
entries: [messageID: JournalEntryFixture(
|
||||
relativePaths: ["images/outgoing/victim.jpg"],
|
||||
recordedAt: fixture.recordedAt
|
||||
)]
|
||||
)
|
||||
try JSONEncoder().encode(journal).write(
|
||||
to: deletionJournal(in: root),
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
#expect(
|
||||
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
.state(for: messageID) == .unavailable
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||
let root = makeRoot("list-failure")
|
||||
@@ -128,25 +351,447 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
||||
let root = makeRoot("failed-tombstone-write")
|
||||
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
|
||||
let root = makeRoot("batch")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
#expect(store.recordDeleted(
|
||||
messageIDs: [secondMessageID, messageID]
|
||||
))
|
||||
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(store.state(for: secondMessageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
|
||||
let root = makeRoot("journal-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
let failing = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataWriter: { _, _, _ in throw TestError() }
|
||||
)
|
||||
#expect(!failing.recordDeleted(
|
||||
messageIDs: [messageID, secondMessageID]
|
||||
))
|
||||
|
||||
// A failed commit must not install a volatile tombstone. Otherwise a
|
||||
// sender retry could be ACKed although the caller kept both bubbles.
|
||||
#expect(failing.state(for: messageID) == .accepted(firstPayload))
|
||||
#expect(
|
||||
failing.state(for: secondMessageID) == .accepted(secondPayload)
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
|
||||
let root = makeRoot("crash-recovery")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: firstPayload
|
||||
))
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: secondPayload
|
||||
))
|
||||
|
||||
let interrupted = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataWriter: { data, url, options in
|
||||
let isJournal =
|
||||
url.lastPathComponent == ".deletion-journal.json"
|
||||
let isFirstRecord =
|
||||
url.deletingPathExtension().lastPathComponent == messageID
|
||||
guard isJournal || isFirstRecord else {
|
||||
throw TestError()
|
||||
}
|
||||
try data.write(to: url, options: options)
|
||||
}
|
||||
)
|
||||
#expect(interrupted.recordDeleted(
|
||||
messageIDs: [messageID, secondMessageID]
|
||||
))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
|
||||
let root = makeRoot("unlink-recovery")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
|
||||
let unlinkFailure = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
payloadRemover: { _ in throw TestError() }
|
||||
)
|
||||
#expect(unlinkFailure.recordDeleted(messageID: messageID))
|
||||
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
|
||||
let root = makeRoot("expired-fallback")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let recordedAt = Date(timeIntervalSince1970: 1_000)
|
||||
let seed = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { recordedAt }
|
||||
)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
// Simulate a receipt pruned by an older app version while its bubble
|
||||
// and payload remain. Current code retains live accepted-path owners.
|
||||
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||
|
||||
let afterExpiry = recordedAt.addingTimeInterval(11)
|
||||
let interrupted = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { afterExpiry },
|
||||
payloadRemover: { _ in throw TestError() }
|
||||
)
|
||||
#expect(interrupted.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 10,
|
||||
now: { afterExpiry }
|
||||
)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
|
||||
let root = makeRoot("retry-suffix")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
|
||||
// Simulate an older build pruning only the receipt. A retry must use a
|
||||
// suffixed filename while the old bubble still references image.jpg.
|
||||
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||
let retry = try makePayload(in: root, name: "image (1).jpg")
|
||||
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(retried.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: retry
|
||||
))
|
||||
|
||||
#expect(retried.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(retried.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
|
||||
let root = makeRoot("protected-ui-owner")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
|
||||
#expect(!store.recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
protectedPayloadRelativePaths: [
|
||||
"images/incoming/image.jpg"
|
||||
]
|
||||
))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
|
||||
let root = makeRoot("pathless-delete")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
|
||||
// Force the atomic record write itself to fail after the store has
|
||||
// successfully loaded its empty index.
|
||||
let record = receiptRecord(in: root)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
|
||||
let root = makeRoot("path-reuse")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let original = try makePayload(in: root)
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: original
|
||||
))
|
||||
#expect(store.recordDeleted(messageID: messageID))
|
||||
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||
|
||||
let reused = try makePayload(in: root)
|
||||
#expect(store.commitAccepted(
|
||||
messageID: secondMessageID,
|
||||
storedURL: reused
|
||||
))
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||
#expect(
|
||||
relaunched.state(for: secondMessageID) == .accepted(reused)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
|
||||
let root = makeRoot("legacy-path-conflict")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let receiptDirectory = receiptRecord(in: root)
|
||||
.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: record,
|
||||
at: receiptDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
try FileManager.default.removeItem(at: record)
|
||||
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(
|
||||
to: receiptRecord(in: root),
|
||||
options: .atomic
|
||||
)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "accepted",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(
|
||||
to: receiptRecord(
|
||||
in: root,
|
||||
messageID: secondMessageID
|
||||
),
|
||||
options: .atomic
|
||||
)
|
||||
|
||||
// The UI must be able to report the deletion failure without a
|
||||
// process-lifetime tombstone silently hiding a later retry.
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(
|
||||
store.state(for: secondMessageID) == .accepted(payload)
|
||||
)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
|
||||
let root = makeRoot("legacy-expired-cleanup")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let receiptURL = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||
try JSONEncoder().encode(ReceiptFixture(
|
||||
kind: "tombstone",
|
||||
relativePath: "images/incoming/image.jpg",
|
||||
recordedAt: recordedAt
|
||||
)).write(to: receiptURL, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
ttl: 1,
|
||||
now: { recordedAt.addingTimeInterval(2) }
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
|
||||
let root = makeRoot("journal-directory")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let directory = root.appendingPathComponent(
|
||||
"files/images/incoming/archive",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let child = directory.appendingPathComponent("child.jpg")
|
||||
try Data([0x01]).write(to: child)
|
||||
let receiptDirectory = receiptRecord(in: root)
|
||||
.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(
|
||||
at: receiptDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).recordDeleted(
|
||||
messageIDs: [messageID],
|
||||
payloadRelativePaths: [
|
||||
messageID: "images/incoming/archive"
|
||||
]
|
||||
))
|
||||
#expect(!FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
try JSONEncoder().encode(JournalFixture(
|
||||
version: 1,
|
||||
entries: [messageID: JournalEntryFixture(
|
||||
relativePaths: ["images/incoming/archive"],
|
||||
recordedAt: Date()
|
||||
)]
|
||||
)).write(to: deletionJournal(in: root), options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .tombstoned)
|
||||
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||
#expect(FileManager.default.fileExists(
|
||||
atPath: deletionJournal(in: root).path
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
|
||||
let root = makeRoot("corrupt-journal")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root
|
||||
).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let journal = deletionJournal(in: root)
|
||||
try Data("{not-json".utf8).write(to: journal, options: .atomic)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||
#expect(!relaunched.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -180,7 +825,10 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
)
|
||||
}
|
||||
|
||||
private func makePayload(in root: URL) throws -> URL {
|
||||
private func makePayload(
|
||||
in root: URL,
|
||||
name: String = "image.jpg"
|
||||
) throws -> URL {
|
||||
let directory = root.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
@@ -189,18 +837,30 @@ struct BLEPrivateMediaReceiptStoreTests {
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = directory.appendingPathComponent("image.jpg")
|
||||
let payload = directory.appendingPathComponent(name)
|
||||
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
private func receiptRecord(in root: URL) -> URL {
|
||||
private func receiptRecord(
|
||||
in root: URL,
|
||||
messageID requestedMessageID: String? = nil
|
||||
) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(messageID)
|
||||
.appendingPathComponent(requestedMessageID ?? messageID)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func deletionJournal(in root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(".deletion-journal.json")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user