mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
P1: Wire didReceivePendingFileTransfer into ChatViewModel - Add implementation that creates system message for pending files - Route to appropriate chat (public/private) - Send local notification for incoming files - Auto-decline files from blocked users P2: Keep pending files in queue if save fails - Only remove file from pendingFiles after successful save - Allows user to retry accept if initial save failed Also adds test for save failure scenario. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
321 lines
11 KiB
Swift
321 lines
11 KiB
Swift
//
|
|
// PendingFileManagerTests.swift
|
|
// bitchatTests
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import bitchat
|
|
|
|
/// Tests for BCH-01-002 fix: PendingFileManager prevents DoS via storage exhaustion
|
|
struct PendingFileManagerTests {
|
|
|
|
@Test("addPendingFile stores file in memory")
|
|
func addPendingFile_storesInMemory() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
let content = Data(repeating: 0x42, count: 1024)
|
|
let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "test.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: content,
|
|
isPrivate: false
|
|
)
|
|
|
|
#expect(pending != nil)
|
|
#expect(pending?.fileSize == 1024)
|
|
#expect(pending?.fileName == "test.bin")
|
|
#expect(manager.stats.count == 1)
|
|
#expect(manager.stats.totalBytes == 1024)
|
|
}
|
|
|
|
@Test("getPendingFile retrieves stored file")
|
|
func getPendingFile_retrievesStoredFile() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
let content = Data(repeating: 0x42, count: 512)
|
|
let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "test.bin",
|
|
mimeType: "image/png",
|
|
content: content,
|
|
isPrivate: false
|
|
)
|
|
|
|
guard let id = pending?.id else {
|
|
Issue.record("Failed to add pending file")
|
|
return
|
|
}
|
|
|
|
let retrieved = manager.getPendingFile(id: id)
|
|
#expect(retrieved != nil)
|
|
#expect(retrieved?.content == content)
|
|
}
|
|
|
|
@Test("declineFile removes file from queue")
|
|
func declineFile_removesFromQueue() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
let content = Data(repeating: 0x42, count: 256)
|
|
let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "test.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: content,
|
|
isPrivate: false
|
|
)
|
|
|
|
guard let id = pending?.id else {
|
|
Issue.record("Failed to add pending file")
|
|
return
|
|
}
|
|
|
|
#expect(manager.stats.count == 1)
|
|
manager.declineFile(id: id)
|
|
#expect(manager.stats.count == 0)
|
|
#expect(manager.getPendingFile(id: id) == nil)
|
|
}
|
|
|
|
@Test("clearAll removes all pending files")
|
|
func clearAll_removesAllFiles() {
|
|
let manager = PendingFileManager(config: .default)
|
|
|
|
for i in 0..<5 {
|
|
_ = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"),
|
|
senderNickname: "User\(i)",
|
|
fileName: "file\(i).bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: UInt8(i), count: 100),
|
|
isPrivate: false
|
|
)
|
|
}
|
|
|
|
#expect(manager.stats.count == 5)
|
|
manager.clearAll()
|
|
#expect(manager.stats.count == 0)
|
|
}
|
|
|
|
@Test("count limit evicts oldest files")
|
|
func countLimit_evictsOldestFiles() {
|
|
let config = PendingFileManager.Config(
|
|
maxPendingCount: 3,
|
|
maxTotalBytes: 1_000_000,
|
|
expirationSeconds: 300
|
|
)
|
|
let manager = PendingFileManager(config: config)
|
|
defer { manager.clearAll() }
|
|
|
|
// Add 3 files (at limit)
|
|
var ids: [String] = []
|
|
for i in 0..<3 {
|
|
if let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"),
|
|
senderNickname: "User\(i)",
|
|
fileName: "file\(i).bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: UInt8(i), count: 100),
|
|
isPrivate: false
|
|
) {
|
|
ids.append(pending.id)
|
|
}
|
|
}
|
|
|
|
#expect(manager.stats.count == 3)
|
|
|
|
// Add 4th file - should evict oldest
|
|
let fourth = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223349"),
|
|
senderNickname: "User9",
|
|
fileName: "file9.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: 0x99, count: 100),
|
|
isPrivate: false
|
|
)
|
|
|
|
#expect(fourth != nil)
|
|
#expect(manager.stats.count == 3) // Still at limit
|
|
#expect(manager.getPendingFile(id: ids[0]) == nil) // Oldest evicted
|
|
#expect(manager.getPendingFile(id: ids[1]) != nil) // Second still exists
|
|
}
|
|
|
|
@Test("size limit evicts files to make room")
|
|
func sizeLimit_evictsFilesToMakeRoom() {
|
|
let config = PendingFileManager.Config(
|
|
maxPendingCount: 100,
|
|
maxTotalBytes: 500, // Very small limit for testing
|
|
expirationSeconds: 300
|
|
)
|
|
let manager = PendingFileManager(config: config)
|
|
defer { manager.clearAll() }
|
|
|
|
// Add 2 files totaling 400 bytes (under limit)
|
|
let first = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223340"),
|
|
senderNickname: "User0",
|
|
fileName: "file0.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: 0x00, count: 200),
|
|
isPrivate: false
|
|
)
|
|
|
|
_ = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223341"),
|
|
senderNickname: "User1",
|
|
fileName: "file1.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: 0x01, count: 200),
|
|
isPrivate: false
|
|
)
|
|
|
|
#expect(manager.stats.count == 2)
|
|
#expect(manager.stats.totalBytes == 400)
|
|
|
|
// Add 300-byte file - needs to evict to fit
|
|
let third = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223342"),
|
|
senderNickname: "User2",
|
|
fileName: "file2.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: 0x02, count: 300),
|
|
isPrivate: false
|
|
)
|
|
|
|
#expect(third != nil)
|
|
// First file should be evicted to make room
|
|
#expect(manager.getPendingFile(id: first!.id) == nil)
|
|
#expect(manager.stats.totalBytes <= 500)
|
|
}
|
|
|
|
@Test("acceptFile saves and removes from queue")
|
|
func acceptFile_savesAndRemovesFromQueue() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
let content = Data(repeating: 0x42, count: 128)
|
|
let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "test.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: content,
|
|
isPrivate: false
|
|
)
|
|
|
|
guard let id = pending?.id else {
|
|
Issue.record("Failed to add pending file")
|
|
return
|
|
}
|
|
|
|
#expect(manager.stats.count == 1)
|
|
|
|
var savedURL: URL?
|
|
let resultURL = manager.acceptFile(id: id) { pending in
|
|
// Simulate saving - just return a fake URL for testing
|
|
savedURL = URL(fileURLWithPath: "/tmp/test-\(pending.id).bin")
|
|
return savedURL
|
|
}
|
|
|
|
#expect(resultURL == savedURL)
|
|
#expect(manager.stats.count == 0) // Removed from queue
|
|
#expect(manager.getPendingFile(id: id) == nil)
|
|
}
|
|
|
|
@Test("getAllPendingFiles returns sorted by timestamp descending")
|
|
func getAllPendingFiles_sortedByTimestampDescending() async throws {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
// Add files with small delays to ensure different timestamps
|
|
for i in 0..<3 {
|
|
_ = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD1122334\(i)"),
|
|
senderNickname: "User\(i)",
|
|
fileName: "file\(i).bin",
|
|
mimeType: "application/octet-stream",
|
|
content: Data(repeating: UInt8(i), count: 50),
|
|
isPrivate: false
|
|
)
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
let all = manager.getAllPendingFiles()
|
|
#expect(all.count == 3)
|
|
|
|
// Should be sorted newest first
|
|
for i in 0..<(all.count - 1) {
|
|
#expect(all[i].timestamp >= all[i + 1].timestamp)
|
|
}
|
|
}
|
|
|
|
@Test("acceptFile keeps file in queue on save failure")
|
|
func acceptFile_keepsFileOnSaveFailure() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
let content = Data(repeating: 0x42, count: 128)
|
|
let pending = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "test.bin",
|
|
mimeType: "application/octet-stream",
|
|
content: content,
|
|
isPrivate: false
|
|
)
|
|
|
|
guard let id = pending?.id else {
|
|
Issue.record("Failed to add pending file")
|
|
return
|
|
}
|
|
|
|
#expect(manager.stats.count == 1)
|
|
|
|
// Simulate save failure by returning nil
|
|
let resultURL = manager.acceptFile(id: id) { _ in
|
|
return nil // Save failed
|
|
}
|
|
|
|
#expect(resultURL == nil)
|
|
#expect(manager.stats.count == 1) // Still in queue for retry
|
|
#expect(manager.getPendingFile(id: id) != nil) // Can still retrieve it
|
|
}
|
|
|
|
@Test("displayName returns fileName or generates default")
|
|
func displayName_returnsFileNameOrGeneratesDefault() {
|
|
let manager = PendingFileManager(config: .default)
|
|
defer { manager.clearAll() }
|
|
|
|
// With fileName
|
|
let withName = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223344"),
|
|
senderNickname: "TestUser",
|
|
fileName: "custom.png",
|
|
mimeType: "image/png",
|
|
content: Data(repeating: 0x42, count: 100),
|
|
isPrivate: false
|
|
)
|
|
#expect(withName?.displayName == "custom.png")
|
|
|
|
// Without fileName - should use extension from MIME
|
|
let withoutName = manager.addPendingFile(
|
|
senderPeerID: PeerID(str: "AABBCCDD11223345"),
|
|
senderNickname: "TestUser2",
|
|
fileName: nil,
|
|
mimeType: "audio/mp3",
|
|
content: Data(repeating: 0x43, count: 100),
|
|
isPrivate: false
|
|
)
|
|
#expect(withoutName?.displayName == "file.mp3")
|
|
}
|
|
}
|