Files

2484 lines
130 KiB
Swift

// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionNetworkingKit
import SessionUtilitiesKit
import Quick
import Nimble
@testable import SessionMessagingKit
class ExtensionHelperSpec: AsyncSpec {
override class func spec() {
// MARK: Configuration
@TestState var dependencies: TestDependencies! = TestDependencies(
initialState: { dependencies in
dependencies.dateNow = Date(timeIntervalSince1970: 1234567890)
dependencies.forceSynchronous = true
}
)
@TestState(singleton: .extensionHelper, in: dependencies) var extensionHelper: ExtensionHelper! = ExtensionHelper(using: dependencies)
@TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage(
customWriter: try! DatabaseQueue(),
migrations: SNMessagingKit.migrations,
using: dependencies
)
@TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto(
initialSetup: { crypto in
crypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
crypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) }
.thenReturn(Data([4, 5, 6]))
}
)
@TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager(
initialSetup: { $0.defaultInitialSetup() }
)
@TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain(
initialSetup: { keychain in
keychain
.when {
try $0.getOrGenerateEncryptionKey(
forKey: .any,
length: .any,
cat: .any,
legacyKey: .any,
legacyService: .any
)
}
.thenReturn(Data([1, 2, 3]))
}
)
@TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache(
initialSetup: { $0.defaultInitialSetup() }
)
@TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache(
initialSetup: { cache in
cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey))
}
)
@TestState var mockLogger: MockLogger! = MockLogger()
// MARK: - an ExtensionHelper - File Management
describe("an ExtensionHelper") {
// MARK: -- can delete the entire cache
it("can delete the entire cache") {
extensionHelper.deleteCache()
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache")
})
}
// MARK: -- when writing an encrypted file
context("when writing an encrypted file") {
// MARK: ---- ensures the write directory exists
it("ensures the write directory exists") {
try? extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.ensureDirectoryExists(at: "/test/extensionCache/conversations/010203/dedupe")
})
}
// MARK: ---- protects the write directory
it("protects the write directory") {
try? extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.protectFileOrFolder(at: "/test/extensionCache/conversations/010203/dedupe")
})
}
// MARK: ---- generates a temporary file path
it("generates a temporary file path") {
try? extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.temporaryFilePath(fileExtension: nil)
})
}
// MARK: ---- writes the encrypted data to the temporary file path
it("writes the encrypted data to the temporary file path") {
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.write(data: Data([4, 5, 6]), toPath: "tmpFile")
})
}
// MARK: ---- replaces the destination path with the temporary file
it("replaces the destination path with the temporary file") {
try? extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/dedupe/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- throws when failing to retrieve the encryption key
it("throws when failing to retrieve the encryption key") {
mockKeychain
.when {
try $0.getOrGenerateEncryptionKey(
forKey: .any,
length: .any,
cat: .any,
legacyKey: .any,
legacyService: .any
)
}
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(ExtensionHelperError.noEncryptionKey))
}
// MARK: ---- throws encryption errors
it("throws encryption errors") {
mockCrypto
.when {
try $0.tryGenerate(
.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)
)
}
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(TestError.mock))
}
// MARK: ---- throws when it fails to write to disk
it("throws when it fails to write to disk") {
mockFileManager
.when { try $0.write(data: .any, toPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(ExtensionHelperError.failedToWriteToFile(TestError.mock)))
}
// MARK: ---- does not throw when attempting to remove an existing item at the destination fails
it("does not throw when attempting to remove an existing item at the destination fails") {
mockFileManager
.when { try $0.removeItem(atPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.toNot(throwError(TestError.mock))
}
// MARK: ---- throws when it fails to move the temp file to the final location
it("throws when it fails to move the temp file to the final location") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(TestError.mock))
}
}
}
// MARK: - an ExtensionHelper - User Metadata
describe("an ExtensionHelper") {
// MARK: -- when saving user metadata
context("when saving user metadata") {
// MARK: ---- saves the file successfully
it("saves the file successfully") {
expect {
try extensionHelper.saveUserMetadata(
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
unreadCount: 1
)
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.write(data: Data(base64Encoded: "BAUG")!, toPath: "tmpFile")
})
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/metadata",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- throws when failing to write the file
it("throws when failing to write the file") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.saveUserMetadata(
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
unreadCount: 1
)
}.to(throwError(TestError.mock))
}
}
// MARK: -- when loading user metadata
context("when loading user metadata") {
// MARK: ---- loads the data correctly
it("loads the data correctly") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(
try! JSONEncoder(using: dependencies)
.encode(
ExtensionHelper.UserMetadata(
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
unreadCount: 1
)
)
)
let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata()
expect(result).toNot(beNil())
expect(result?.sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey)))
expect(result?.ed25519SecretKey).to(equal(Array(Data(hex: TestConstants.edSecretKey))))
expect(result?.unreadCount).to(equal(1))
}
// MARK: ---- returns null if there is no file
it("returns null if there is no file") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(nil)
let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata()
expect(result).to(beNil())
}
// MARK: ---- returns null if it fails to decrypt the file
it("returns null if it fails to decrypt the file") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(nil)
let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata()
expect(result).to(beNil())
}
// MARK: ---- returns null if it fails to decode the data
it("returns null if it fails to decode the data") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
let result: ExtensionHelper.UserMetadata? = extensionHelper.loadUserMetadata()
expect(result).to(beNil())
}
}
}
// MARK: - an ExtensionHelper - Deduping
describe("an ExtensionHelper") {
// MARK: -- when checking whether it has a dedupe record since the last clear
context("when checking whether it has a dedupe record since the last clear") {
// MARK: ---- returns true when at least one record exists that is newer than the last cleared timestamp
it("returns true when at least one record exists that is newer than the last cleared timestamp") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beTrue())
}
// MARK: ---- returns false when it cannot get the conversation path
it("returns false when it cannot get the conversation path") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse())
}
// MARK: ---- returns false when a record does not exist
it("returns false when a record does not exist") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)])
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse())
}
// MARK: ---- returns false when a record exists but is too old
it("returns false when a record exists but is too old") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") }
.thenReturn([FileAttributeKey.modificationDate: Date(timeIntervalSince1970: 1234567900)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)])
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse())
}
// MARK: ---- ignores the lastCleared file when comparing dedupe records
it("ignores the lastCleared file when comparing dedupe records") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.contentsOfDirectory(atPath: .any) }
.thenReturn(["010203"])
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beFalse())
}
// MARK: ---- returns true when at least one record exists and there is no last cleared timestamp
it("returns true when at least one record exists and there is no last cleared timestamp") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3])
mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn(["Test1234"])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203") }
.thenReturn([:])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/010203/dedupe/Test1234") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.hasDedupeRecordSinceLastCleared(threadId: "threadId")).to(beTrue())
}
}
// MARK: -- when checking for dedupe records
context("when checking for dedupe records") {
// MARK: ---- returns true when a record exists
it("returns true when a record exists") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
expect(extensionHelper.dedupeRecordExists(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)).to(beTrue())
}
// MARK: ---- returns false when a record does not exist
it("returns false when a record does not exist") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false)
expect(extensionHelper.dedupeRecordExists(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)).to(beFalse())
}
// MARK: ---- returns false when failing to generate a hash
it("returns false when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect(extensionHelper.dedupeRecordExists(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)).to(beFalse())
}
}
// MARK: -- when creating dedupe records
context("when creating dedupe records") {
// MARK: ---- writes the file successfully
it("writes the file successfully") {
try? extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/dedupe/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- throws when failing to generate a hash
it("throws when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(ExtensionHelperError.failedToStoreDedupeRecord))
}
// MARK: ---- throws when failing to write the file
it("throws when failing to write the file") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.createDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(TestError.mock))
}
}
// MARK: -- when removing dedupe records
context("when removing dedupe records") {
// MARK: ---- removes the file successfully
it("removes the file successfully") {
try? extensionHelper.removeDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203")
})
}
// MARK: ---- removes the parent directory if it is empty
it("removes the parent directory if it is empty") {
try? extensionHelper.removeDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe")
})
}
// MARK: ---- leaves the parent directory if not empty
it("leaves the parent directory if not empty") {
mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false)
try? extensionHelper.removeDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe")
})
}
// MARK: ---- does nothing when failing to generate a hash
it("does nothing when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect {
try extensionHelper.removeDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.toNot(throwError(ExtensionHelperError.failedToStoreDedupeRecord))
expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203")
})
}
// MARK: ---- throws when failing to remove the file
it("throws when failing to remove the file") {
mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock)
expect {
try extensionHelper.removeDedupeRecord(
threadId: "threadId",
uniqueIdentifier: "uniqueId"
)
}.to(throwError(TestError.mock))
}
}
// MARK: -- when upserting a last cleared record
context("when upserting a last cleared record") {
// MARK: ---- creates the file successfully
it("creates the file successfully") {
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) }
.thenReturn(Data())
expect {
try extensionHelper.upsertLastClearedRecord(threadId: "threadId")
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.write(data: Data(), toPath: "tmpFile")
})
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/dedupe/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- throws when failing to generate a hash
it("throws when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect {
try extensionHelper.upsertLastClearedRecord(threadId: "threadId")
}.to(throwError(ExtensionHelperError.failedToUpdateLastClearedRecord))
expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) {
try? $0.removeItem(atPath: "/test/extensionCache/conversations/010203/dedupe/010203")
})
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- throws when failing to write the file
it("throws when failing to write the file") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.upsertLastClearedRecord(threadId: "threadId")
}.to(throwError(TestError.mock))
}
}
}
// MARK: - an ExtensionHelper - Config Dumps
describe("an ExtensionHelper") {
beforeEach {
Log.setup(with: mockLogger)
}
// MARK: -- when retrieving the last updated timestamp
context("when retrieving the last updated timestamp") {
// MARK: ---- returns the timestamp
it("returns the timestamp") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567890)])
expect(extensionHelper.lastUpdatedTimestamp(
for: SessionId(.standard, hex: TestConstants.publicKey),
variant: .userProfile
)).to(equal(1234567890))
}
// MARK: ---- returns zero when it fails to generate a hash
it("returns zero when it fails to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect(extensionHelper.lastUpdatedTimestamp(
for: SessionId(.standard, hex: TestConstants.publicKey),
variant: .userProfile
)).to(equal(0))
}
// MARK: ---- throws when failing to retrieve file metadata
it("throws when failing to retrieve file metadata") {
mockFileManager.when { try $0.attributesOfItem(atPath: .any) }.thenReturn(nil)
expect(extensionHelper.lastUpdatedTimestamp(
for: SessionId(.standard, hex: TestConstants.publicKey),
variant: .userProfile
)).to(equal(0))
}
}
// MARK: -- when replicating a config dump
context("when replicating a config dump") {
// MARK: ---- replicates successfully
it("replicates successfully") {
extensionHelper.replicate(
dump: ConfigDump(
variant: .userProfile,
sessionId: "05\(TestConstants.publicKey)",
data: Data([1, 2, 3]),
timestampMs: 1234567890
),
replaceExisting: true
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.write(data: Data(base64Encoded: "BAUG")!, toPath: "tmpFile")
})
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/dumps/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- does nothing when given a null dump
it("does nothing when given a null dump") {
extensionHelper.replicate(dump: nil, replaceExisting: true)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing when failing to generate a hash
it("does nothing when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
extensionHelper.replicate(
dump: ConfigDump(
variant: .userProfile,
sessionId: "05\(TestConstants.publicKey)",
data: Data([1, 2, 3]),
timestampMs: 1234567890
),
replaceExisting: true
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing a file already exists and we do not want to replace it
it("does nothing a file already exists and we do not want to replace it") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
extensionHelper.replicate(
dump: ConfigDump(
variant: .userProfile,
sessionId: "05\(TestConstants.publicKey)",
data: Data([1, 2, 3]),
timestampMs: 1234567890
),
replaceExisting: false
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- logs an error when failing to write the file
it("logs an error when failing to write the file") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
extensionHelper.replicate(
dump: ConfigDump(
variant: .userProfile,
sessionId: "05\(TestConstants.publicKey)",
data: Data([1, 2, 3]),
timestampMs: 1234567890
),
replaceExisting: true
)
await expect { await mockLogger.logs }.toEventually(equal([
MockLogger.LogOutput(
level: .error,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Failed to replicate userProfile dump for 05\(TestConstants.publicKey) due to error: mock.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "replicate(dump:replaceExisting:)"
)
]))
}
}
// MARK: -- when replicating all config dumps
context("when replicating all config dumps") {
struct DumpReplicationValues {
let key: String
let sessionId: String?
let variant: ConfigDump.Variant?
let hashValue: [UInt8]
let plaintext: Data
let ciphertext: Data
init(_ key: String, _ value: [UInt8]) {
self.key = key
self.sessionId = nil
self.variant = nil
self.hashValue = value
self.plaintext = Data(value + value)
self.ciphertext = Data(value)
}
init(_ variant: ConfigDump.Variant, _ value: [UInt8]) {
self.key = "DumpSalt-\(variant)"
self.sessionId = (ConfigDump.Variant.userVariants.contains(variant) ?
"05\(TestConstants.publicKey)" :
"03\(TestConstants.publicKey)"
)
self.variant = variant
self.hashValue = value
self.plaintext = Data(value + value)
self.ciphertext = Data(value)
}
}
@TestState var mockValues: [DumpReplicationValues]! = [
DumpReplicationValues("ConvoIdSalt-05\(TestConstants.publicKey)", [1, 2, 3]),
DumpReplicationValues(ConfigDump.Variant.userProfile, [2, 3, 4]),
DumpReplicationValues(ConfigDump.Variant.contacts, [3, 4, 5]),
DumpReplicationValues(ConfigDump.Variant.convoInfoVolatile, [4, 5, 6]),
DumpReplicationValues(ConfigDump.Variant.userGroups, [5, 6, 7]),
DumpReplicationValues(ConfigDump.Variant.local, [6, 7, 8]),
DumpReplicationValues("ConvoIdSalt-03\(TestConstants.publicKey)", [9, 8, 7]),
DumpReplicationValues(ConfigDump.Variant.groupInfo, [8, 7, 6]),
DumpReplicationValues(ConfigDump.Variant.groupMembers, [7, 6, 5]),
DumpReplicationValues(ConfigDump.Variant.groupKeys, [6, 5, 4])
]
beforeEach {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(nil)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)])
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
mockValues.forEach { value in
mockCrypto
.when { $0.generate(.hash(message: Array(value.key.data(using: .utf8)!))) }
.thenReturn(value.hashValue)
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: value.plaintext, encKey: .any)) }
.thenReturn(value.ciphertext)
}
mockStorage.write { db in
try mockValues.forEach { values in
guard
let sessionId: String = values.sessionId,
let variant: ConfigDump.Variant = values.variant
else { return }
try ConfigDump(
variant: variant,
sessionId: sessionId,
data: values.plaintext,
timestampMs: 1234567890
).insert(db)
}
}
}
// MARK: ---- replicates successfully
it("replicates successfully") {
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.write(data: .any, toPath: .any) })
.toEventually(haveCount(5))
.retrieveValue()
let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
.toEventually(haveCount(5))
.retrieveValue()
let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))"
expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[Data(base64Encoded: AgME), tmpFile]",
"[Data(base64Encoded: AwQF), tmpFile]",
"[Data(base64Encoded: BAUG), tmpFile]",
"[Data(base64Encoded: BQYH), tmpFile]",
"[Data(base64Encoded: BgcI), tmpFile]"
]))
expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[/test/extensionCache/conversations/010203/dumps/020304, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/030405, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/040506, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/050607, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/060708, tmpFile, nil, \(emptyOptions)]"
]))
}
// MARK: ---- replicates all user configs if they cannot be found
it("replicates all user configs if they cannot be found") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false)
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.write(data: .any, toPath: .any) })
.toEventually(haveCount(5))
.retrieveValue()
let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
.toEventually(haveCount(5))
.retrieveValue()
let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))"
expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[Data(base64Encoded: AgME), tmpFile]",
"[Data(base64Encoded: AwQF), tmpFile]",
"[Data(base64Encoded: BAUG), tmpFile]",
"[Data(base64Encoded: BQYH), tmpFile]",
"[Data(base64Encoded: BgcI), tmpFile]"
]))
expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[/test/extensionCache/conversations/010203/dumps/020304, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/030405, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/040506, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/050607, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/dumps/060708, tmpFile, nil, \(emptyOptions)]"
]))
}
// MARK: ---- replicates all configs for a group if one cannot be found
it("replicates all configs for a group if one cannot be found") {
mockValues.forEach { value in
guard let variant: ConfigDump.Variant = value.variant else { return }
let isGroupVariant: Bool = ConfigDump.Variant.groupVariants.contains(variant)
let convo: String = (isGroupVariant ? "090807" : "010203")
let dump: String = value.hashValue.toHexString()
mockFileManager
.when {
$0.fileExists(
atPath: "/test/extensionCache/conversations/\(convo)/dumps/\(dump)"
)
}
.thenReturn(!isGroupVariant)
}
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [
SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
SessionId(.group, hex: "03\(TestConstants.publicKey)"),
]
)
let allCreateFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.write(data: .any, toPath: .any) })
.toEventually(haveCount(3))
.retrieveValue()
let allMoveFileCalls: [CallDetails]? = await expect(mockFileManager
.allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
.toEventually(haveCount(3))
.retrieveValue()
let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))"
expect((allCreateFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[Data(base64Encoded: BgUE), tmpFile]",
"[Data(base64Encoded: BwYF), tmpFile]",
"[Data(base64Encoded: CAcG), tmpFile]"
]))
expect((allMoveFileCalls?.map { $0.parameterSummary }).map { Set($0) })
.to(equal([
"[/test/extensionCache/conversations/090807/dumps/060504, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/090807/dumps/070605, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/090807/dumps/080706, tmpFile, nil, \(emptyOptions)]"
]))
}
// MARK: ---- does nothing when failing to generate a hash
it("does nothing when failing to generate a hash") {
mockValues.forEach { value in
mockCrypto
.when { $0.generate(.hash(message: Array(value.key.data(using: .utf8)!))) }
.thenReturn(nil)
}
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing if valid dumps already exist
it("does nothing if valid dumps already exist") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing if there are no dumps in the database
it("does nothing if there are no dumps in the database") {
mockStorage.write { db in try ConfigDump.deleteAll(db) }
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: []
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing if the existing replicated dump was newer than the fetched one
it("does nothing if the existing replicated dump was newer than the fetched one") {
dependencies.dateNow = Date(timeIntervalSince1970: 1234567890)
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567891)])
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing if it fails to replicate
it("does nothing if it fails to replicate") {
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([2, 3, 4]), encKey: .any)) }
.thenThrow(TestError.mock)
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([5, 6, 7]), encKey: .any)) }
.thenThrow(TestError.mock)
extensionHelper.replicateAllConfigDumpsIfNeeded(
userSessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
allDumpSessionIds: [SessionId(.standard, hex: "05\(TestConstants.publicKey)")]
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
}
// MARK: -- when refreshing the dump modified date
context("when refreshing the dump modified date") {
beforeEach {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
}
// MARK: ---- updates the modified date
it("updates the modified date") {
extensionHelper.refreshDumpModifiedDate(
sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
variant: .userProfile
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.setAttributes(
[.modificationDate: Date(timeIntervalSince1970: 1234567890)],
ofItemAtPath: "/test/extensionCache/conversations/010203/dumps/010203"
)
})
}
// MARK: ---- does nothing when it fails to generate a hash
it("does nothing when it fails to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
extensionHelper.refreshDumpModifiedDate(
sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
variant: .userProfile
)
expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) })
}
// MARK: ---- does nothing if the file does not exist
it("does nothing if the file does not exist") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false)
extensionHelper.refreshDumpModifiedDate(
sessionId: SessionId(.standard, hex: "05\(TestConstants.publicKey)"),
variant: .userProfile
)
expect(mockFileManager).toNot(call { try $0.setAttributes(.any, ofItemAtPath: .any) })
}
}
// MARK: -- when loading user configs
context("when loading user configs") {
beforeEach {
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
}
// MARK: ---- sets the configs for each of the user variants
it("sets the configs for each of the user variants") {
let ptr: UnsafeMutablePointer<config_object> = UnsafeMutablePointer<config_object>.allocate(capacity: 1)
let configs: [LibSession.Config] = [
.userProfile(ptr), .userGroups(ptr), .contacts(ptr), .convoInfoVolatile(ptr)
]
configs.forEach { config in
mockLibSessionCache
.when {
try $0.loadState(
for: config.variant,
sessionId: .any,
userEd25519SecretKey: .any,
groupEd25519SecretKey: .any,
cachedData: .any
)
}
.thenReturn(config)
}
extensionHelper.loadUserConfigState(
into: mockLibSessionCache,
userSessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))
)
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .userProfile,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
to: .userProfile(ptr)
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .userGroups,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
to: .userGroups(ptr)
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .contacts,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
to: .contacts(ptr)
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .convoInfoVolatile,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
to: .convoInfoVolatile(ptr)
)
})
ptr.deallocate()
}
// MARK: ---- loads the default states when failing to load config data
it("loads the default states when failing to load config data") {
mockLibSessionCache
.when {
try $0.loadState(
for: .any,
sessionId: .any,
userEd25519SecretKey: .any,
groupEd25519SecretKey: .any,
cachedData: .any
)
}
.thenReturn(nil)
extensionHelper.loadUserConfigState(
into: mockLibSessionCache,
userSessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))
)
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.loadDefaultStateFor(
variant: .userProfile,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
groupEd25519SecretKey: nil
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.loadDefaultStateFor(
variant: .userGroups,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
groupEd25519SecretKey: nil
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.loadDefaultStateFor(
variant: .contacts,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
groupEd25519SecretKey: nil
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.loadDefaultStateFor(
variant: .convoInfoVolatile,
sessionId: SessionId(.standard, hex: TestConstants.publicKey),
userEd25519SecretKey: Array(Data(hex: TestConstants.edSecretKey)),
groupEd25519SecretKey: nil
)
})
}
}
// MARK: -- when loading group configs
context("when loading group configs") {
beforeEach {
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
}
// MARK: ---- sets the configs for each of the group variants
it("sets the configs for each of the group variants") {
let ptr: UnsafeMutablePointer<config_object> = UnsafeMutablePointer<config_object>.allocate(capacity: 1)
let keysPtr: UnsafeMutablePointer<config_group_keys> = UnsafeMutablePointer<config_group_keys>.allocate(capacity: 1)
let configs: [LibSession.Config] = [
.groupKeys(keysPtr, info: ptr, members: ptr), .groupMembers(ptr), .groupInfo(ptr)
]
configs.forEach { config in
mockLibSessionCache
.when {
try $0.loadState(
for: config.variant,
sessionId: .any,
userEd25519SecretKey: .any,
groupEd25519SecretKey: .any,
cachedData: .any
)
}
.thenReturn(config)
}
expect {
try extensionHelper.loadGroupConfigStateIfNeeded(
into: mockLibSessionCache,
swarmPublicKey: "03\(TestConstants.publicKey)",
userEd25519SecretKey: [1, 2, 3]
)
}.toNot(throwError())
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .groupKeys,
sessionId: SessionId(.group, hex: TestConstants.publicKey),
to: .groupKeys(keysPtr, info: ptr, members: ptr)
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .groupMembers,
sessionId: SessionId(.group, hex: TestConstants.publicKey),
to: .groupMembers(ptr)
)
})
expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.setConfig(
for: .groupInfo,
sessionId: SessionId(.group, hex: TestConstants.publicKey),
to: .groupInfo(ptr)
)
})
keysPtr.deallocate()
ptr.deallocate()
}
// MARK: ---- returns correct config load results
it("returns correct config load results") {
let ptr: UnsafeMutablePointer<config_object> = UnsafeMutablePointer<config_object>.allocate(capacity: 1)
let keysPtr: UnsafeMutablePointer<config_group_keys> = UnsafeMutablePointer<config_group_keys>.allocate(capacity: 1)
let configs: [LibSession.Config] = [
.groupKeys(keysPtr, info: ptr, members: ptr), .groupInfo(ptr)
]
mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) }
configs.forEach { config in
mockLibSessionCache
.when {
try $0.loadState(
for: config.variant,
sessionId: .any,
userEd25519SecretKey: .any,
groupEd25519SecretKey: .any,
cachedData: .any
)
}
.thenReturn(config)
mockCrypto
.when { $0.generate(.hash(message: Array("DumpSalt-\(config.variant)".utf8))) }
.thenReturn([0, 1, 2])
}
mockCrypto
.when { $0.generate(.hash(message: Array("ConvoIdSalt-03\(TestConstants.publicKey)".utf8))) }
.thenReturn([4, 5, 6])
mockCrypto
.when {
$0.generate(.hash(message: Array("DumpSalt-\(ConfigDump.Variant.groupMembers)".utf8)))
}
.thenReturn(nil)
var result: [ConfigDump.Variant: Bool] = [:]
expect {
result = try extensionHelper.loadGroupConfigStateIfNeeded(
into: mockLibSessionCache,
swarmPublicKey: "03\(TestConstants.publicKey)",
userEd25519SecretKey: [1, 2, 3]
)
}.toNot(throwError())
expect(result).to(equal([
ConfigDump.Variant.groupKeys: true,
ConfigDump.Variant.groupMembers: false,
ConfigDump.Variant.groupInfo: true
]))
keysPtr.deallocate()
ptr.deallocate()
}
// MARK: ---- does nothing if it cannot get a dump for the config
it("does nothing if it cannot get a dump for the config") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(nil)
expect {
try extensionHelper.loadGroupConfigStateIfNeeded(
into: mockLibSessionCache,
swarmPublicKey: "03\(TestConstants.publicKey)",
userEd25519SecretKey: [1, 2, 3]
)
}.toNot(throwError())
expect(mockLibSessionCache).toNot(call {
$0.setConfig(for: .any, sessionId: .any, to: .any)
})
}
// MARK: ---- does nothing if the provided public key is not for a group
it("does nothing if the provided public key is not for a group") {
expect {
try extensionHelper.loadGroupConfigStateIfNeeded(
into: mockLibSessionCache,
swarmPublicKey: "05\(TestConstants.publicKey)",
userEd25519SecretKey: [1, 2, 3]
)
}.toNot(throwError())
expect(mockLibSessionCache).toNot(call {
$0.setConfig(for: .any, sessionId: .any, to: .any)
})
}
}
}
// MARK: - an ExtensionHelper - Notification Settings
describe("an ExtensionHelper") {
struct NotificationSettings: Codable {
let threadId: String
let mentionsOnly: Bool
let mutedUntil: TimeInterval?
}
// MARK: -- when replicating notification settings
context("when replicating notification settings") {
beforeEach {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(nil)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567800)])
mockCrypto
.when { $0.generate(.hash(message: .any)) }
.thenReturn([0, 1, 2])
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
}
// MARK: ---- replicates successfully
it("replicates successfully") {
try? extensionHelper.replicate(
settings: [
"Test1": Preferences.NotificationSettings(
previewType: .nameAndPreview,
sound: .note,
mentionsOnly: false,
mutedUntil: nil
)
],
replaceExisting: true
)
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.write(data: Data(base64Encoded: "BAUG")!, toPath: "tmpFile")
})
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/notificationSettings",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- excludes values with default settings
it("excludes values with default settings") {
let expectedResult: [NotificationSettings] = [
NotificationSettings(
threadId: "Test2",
mentionsOnly: false,
mutedUntil: 1234
)
]
try? extensionHelper.replicate(
settings: [
"Test1": Preferences.NotificationSettings(
previewType: .nameAndPreview,
sound: .note,
mentionsOnly: false,
mutedUntil: nil
),
"Test2": Preferences.NotificationSettings(
previewType: .nameAndPreview,
sound: .note,
mentionsOnly: false,
mutedUntil: 1234
)
],
replaceExisting: true
)
expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) {
$0.generate(
.ciphertextWithXChaCha20(
plaintext: try JSONEncoder(using: dependencies)
.encode(expectedResult),
encKey: [1, 2, 3]
)
)
})
}
// MARK: ---- does nothing if the settings already exist and we do not want to replace existing
it("does nothing if the settings already exist and we do not want to replace existing") {
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
try? extensionHelper.replicate(
settings: [
"Test1": Preferences.NotificationSettings(
previewType: .nameAndPreview,
sound: .note,
mentionsOnly: false,
mutedUntil: nil
)
],
replaceExisting: false
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- does nothing if it fails to replicate
it("does nothing if it fails to replicate") {
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([2, 3, 4]), encKey: .any)) }
.thenThrow(TestError.mock)
mockCrypto
.when { $0.generate(.ciphertextWithXChaCha20(plaintext: Data([5, 6, 7]), encKey: .any)) }
.thenThrow(TestError.mock)
try? extensionHelper.replicate(
settings: [
"Test1": Preferences.NotificationSettings(
previewType: .nameAndPreview,
sound: .note,
mentionsOnly: false,
mutedUntil: nil
)
],
replaceExisting: true
)
expect(mockFileManager).toNot(call { try $0.write(data: .any, toPath: .any) })
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
}
// MARK: -- when loading notification settings
context("when loading notification settings") {
// MARK: ---- loads the data correctly
it("loads the data correctly") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(
try! JSONEncoder(using: dependencies)
.encode(
[
NotificationSettings(
threadId: "Test1",
mentionsOnly: false,
mutedUntil: nil
),
NotificationSettings(
threadId: "Test2",
mentionsOnly: true,
mutedUntil: 12345
)
]
)
)
let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings(
previewType: .nameAndPreview,
sound: .note
)
try require(result).toNot(beNil())
try require(result?["Test1"]).toNot(beNil())
try require(result?["Test2"]).toNot(beNil())
expect(result?["Test1"]?.previewType).to(equal(.nameAndPreview))
expect(result?["Test1"]?.sound).to(equal(.note))
expect(result?["Test1"]?.mentionsOnly).to(beFalse())
expect(result?["Test1"]?.mutedUntil).to(beNil())
expect(result?["Test2"]?.previewType).to(equal(.nameAndPreview))
expect(result?["Test2"]?.sound).to(equal(.note))
expect(result?["Test2"]?.mentionsOnly).to(beTrue())
expect(result?["Test2"]?.mutedUntil).to(equal(12345))
}
// MARK: ---- returns null if there is no file
it("returns null if there is no file") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(nil)
let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings(
previewType: .nameAndPreview,
sound: .note
)
expect(result).to(beNil())
}
// MARK: ---- returns null if it fails to decrypt the file
it("returns null if it fails to decrypt the file") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(nil)
let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings(
previewType: .nameAndPreview,
sound: .note
)
expect(result).to(beNil())
}
// MARK: ---- returns null if it fails to decode the data
it("returns null if it fails to decode the data") {
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(Data([1, 2, 3]))
let result: [String: Preferences.NotificationSettings]? = extensionHelper.loadNotificationSettings(
previewType: .nameAndPreview,
sound: .note
)
expect(result).to(beNil())
}
}
}
// MARK: - an ExtensionHelper - Messages
describe("an ExtensionHelper") {
beforeEach {
Log.setup(with: mockLogger)
}
// MARK: -- when retrieving the unread message count
context("when retrieving the unread message count") {
// MARK: ---- returns the count
it("returns the count") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["b", "c", "d", "e", "f"])
mockCrypto
.when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) }
.thenReturn([3, 4, 5])
let validPaths: [String] = [
"/test/extensionCache/conversations/a/unread",
"/test/extensionCache/conversations/a/unread/b",
"/test/extensionCache/conversations/a/unread/c",
"/test/extensionCache/conversations/a/unread/d",
"/test/extensionCache/conversations/a/unread/e",
"/test/extensionCache/conversations/a/unread/f"
]
validPaths.forEach { path in
mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true)
}
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn(false)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.unreadMessageCount()).to(equal(5))
}
// MARK: ---- adds the total from multiple conversations
it("adds the total from multiple conversations") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a", "b"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["c", "d", "e"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/b/unread"
)
}
.thenReturn(["f", "g", "h"])
let validPaths: [String] = [
"/test/extensionCache/conversations/a/unread",
"/test/extensionCache/conversations/a/unread/c",
"/test/extensionCache/conversations/a/unread/d",
"/test/extensionCache/conversations/a/unread/e",
"/test/extensionCache/conversations/b/unread",
"/test/extensionCache/conversations/b/unread/f",
"/test/extensionCache/conversations/b/unread/g",
"/test/extensionCache/conversations/b/unread/h"
]
validPaths.forEach { path in
mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true)
}
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn(false)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.unreadMessageCount()).to(equal(6))
}
// MARK: ---- only counts message requests as a single item even with multiple unread messages
it("only counts message requests as a single item even with multiple unread messages") {
mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) }
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["030405", "b", "c", "d"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/dedupe"
)
}
.thenReturn(["b1", "b1-legacy", "c1", "c1-legacy", "d1", "d1-legacy"])
mockCrypto
.when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) }
.thenReturn([3, 4, 5])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
let validPaths: [String] = [
"/test/extensionCache/conversations/a/unread/b",
"/test/extensionCache/conversations/a/unread/c",
"/test/extensionCache/conversations/a/unread/d",
"/test/extensionCache/conversations/a/dedupe/b1",
"/test/extensionCache/conversations/a/dedupe/b1-legacy",
"/test/extensionCache/conversations/a/dedupe/c1",
"/test/extensionCache/conversations/a/dedupe/c1-legacy",
"/test/extensionCache/conversations/a/dedupe/d1",
"/test/extensionCache/conversations/a/dedupe/d1-legacy"
]
validPaths.forEach { path in
mockFileManager
.when { try $0.attributesOfItem(atPath: path) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
}
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234560000)])
expect(extensionHelper.unreadMessageCount()).to(equal(1))
}
// MARK: ---- ignores hidden files in the conversations directory
it("ignores hidden files in the conversations directory") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn([".test", "a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["b", "c", "d", "e", "f"])
let validPaths: [String] = [
"/test/extensionCache/conversations/a/unread",
"/test/extensionCache/conversations/a/unread/b",
"/test/extensionCache/conversations/a/unread/c",
"/test/extensionCache/conversations/a/unread/d",
"/test/extensionCache/conversations/a/unread/e",
"/test/extensionCache/conversations/a/unread/f",
"/test/extensionCache/conversations/a/unread/g"
]
validPaths.forEach { path in
mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true)
}
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn(false)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.unreadMessageCount()).to(equal(5))
}
// MARK: ---- ignores hidden files in the unread directory
it("ignores hidden files in the unread directory") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn([".test", "b", "c", "d", "e", "f"])
let validPaths: [String] = [
"/test/extensionCache/conversations/a/unread",
"/test/extensionCache/conversations/a/unread/b",
"/test/extensionCache/conversations/a/unread/c",
"/test/extensionCache/conversations/a/unread/d",
"/test/extensionCache/conversations/a/unread/e",
"/test/extensionCache/conversations/a/unread/f",
"/test/extensionCache/conversations/a/unread/g"
]
validPaths.forEach { path in
mockFileManager.when { $0.fileExists(atPath: path) }.thenReturn(true)
}
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn(false)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567890)])
expect(extensionHelper.unreadMessageCount()).to(equal(5))
}
// MARK: ---- ignores conversations without an unread directory
it("ignores conversations without an unread directory") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a", "b"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["c", "d", "e"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/b/unread"
)
}
.thenReturn(["f", "g", "h"])
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/a/unread") }
.thenReturn(true)
mockFileManager
.when { $0.fileExists(atPath: "/test/extensionCache/conversations/b/unread") }
.thenReturn(false)
mockFileManager
.when { try $0.attributesOfItem(atPath: .any) }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.unreadMessageCount()).to(equal(3))
}
// MARK: ---- ignores message request conversations if the user has seen the message requests stub
it("ignores message request conversations if the user has seen the message requests stub") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenReturn(["b", "c", "d", "e", "f"])
mockCrypto
.when { $0.generate(.hash(message: Array("a".utf8) + Array("messageRequest".utf8))) }
.thenReturn([3, 4, 5])
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/b") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567600)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/c") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567700)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/d") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567800)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/e") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/f") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234568000)])
mockFileManager
.when { try $0.attributesOfItem(atPath: "/test/extensionCache/conversations/a/unread/030405") }
.thenReturn([FileAttributeKey.creationDate: Date(timeIntervalSince1970: 1234567900)])
expect(extensionHelper.unreadMessageCount()).to(equal(0))
}
// MARK: ---- returns null if retrieving the conversation hashes throws
it("returns null if retrieving the conversation hashes throws") {
mockFileManager
.when { try $0.contentsOfDirectory(atPath: .any) }
.thenThrow(TestError.mock)
expect(extensionHelper.unreadMessageCount()).to(beNil())
}
// MARK: ---- returns null if retrieving the conversation hashes throws
it("returns null if retrieving the conversation hashes throws") {
mockFileManager.removeMocksFor { try $0.contentsOfDirectory(atPath: .any) }
mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true)
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/a/unread"
)
}
.thenThrow(TestError.mock)
expect(extensionHelper.unreadMessageCount()).to(beNil())
}
}
// MARK: -- when saving a message
context("when saving a message") {
// MARK: ---- saves the message correctly
it("saves the message correctly") {
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: false,
isMessageRequest: false
)
}.toNot(throwError())
}
// MARK: ---- saves config messages to the correct path
it("saves config messages to the correct path") {
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .configUserProfile,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: false,
isMessageRequest: false
)
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/config/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- saves unread standard messages to the correct path
it("saves unread standard messages to the correct path") {
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: true,
isMessageRequest: false
)
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
_ = try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/unread/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- writes the message request stub file for unread message request messages
it("writes the message request stub file for unread message request messages") {
mockCrypto.removeMocksFor { $0.generate(.hash(message: .any)) }
mockCrypto
.when { $0.generate(.hash(message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".utf8))) }
.thenReturn([1, 2, 3])
mockCrypto
.when { $0.generate(.hash(message: Array("UnreadMessageSalt-TestHash".utf8))) }
.thenReturn([2, 3, 4])
mockCrypto
.when { $0.generate(.hash(message: [1, 2, 3] + Array("messageRequest".utf8))) }
.thenReturn([3, 4, 5])
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: true,
isMessageRequest: true
)
}.toNot(throwError())
let emptyOptions: String = "Optional(__C.NSFileManagerItemReplacementOptions(rawValue: 0))"
expect(mockFileManager
.allCalls { try $0.replaceItem(atPath: .any, withItemAtPath: .any) }?
.map { $0.parameterSummary }
)
.to(equal([
"[/test/extensionCache/conversations/010203/unread/030405, tmpFile, nil, \(emptyOptions)]",
"[/test/extensionCache/conversations/010203/unread/020304, tmpFile, nil, \(emptyOptions)]"
]))
}
// MARK: ---- saves read standard messages to the correct path
it("saves read standard messages to the correct path") {
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: false,
isMessageRequest: false
)
}.toNot(throwError())
expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) {
try $0.replaceItem(
atPath: "/test/extensionCache/conversations/010203/read/010203",
withItemAtPath: "tmpFile"
)
})
}
// MARK: ---- does nothing when failing to generate a hash
it("does nothing when failing to generate a hash") {
mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil)
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: false,
isMessageRequest: false
)
}.toNot(throwError())
expect(mockFileManager).toNot(call { try $0.replaceItem(atPath: .any, withItemAtPath: .any) })
}
// MARK: ---- throws when failing to write the file
it("throws when failing to write the file") {
mockFileManager
.when { _ = try $0.replaceItem(atPath: .any, withItemAtPath: .any) }
.thenThrow(TestError.mock)
expect {
try extensionHelper.saveMessage(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
),
threadId: "05\(TestConstants.publicKey)",
isUnread: false,
isMessageRequest: false
)
}.to(throwError(TestError.mock))
}
}
// MARK: -- when waiting for messages to be loaded
context("when waiting for messages to be loaded") {
// MARK: ---- stops waiting once messages are loaded
it("stops waiting once messages are loaded") {
Task {
try? await Task.sleep(for: .milliseconds(10))
try? await extensionHelper.loadMessages()
}
await expect {
await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(150))
}.to(beTrue())
}
// MARK: ---- times out if it takes longer than the timeout specified
it("times out if it takes longer than the timeout specified") {
await expect {
await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(50))
}.to(beFalse())
}
// MARK: ---- does not wait if messages have already been loaded
it("does not wait if messages have already been loaded") {
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
await expect {
await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(100))
}.to(beTrue())
}
// MARK: ---- waits if messages have already been loaded but we indicate we will load them again
it("waits if messages have already been loaded but we indicate we will load them again") {
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
extensionHelper.willLoadMessages()
await expect {
await extensionHelper.waitUntilMessagesAreLoaded(timeout: .milliseconds(50))
}.to(beFalse())
}
}
// MARK: -- when loading messages
context("when loading messages") {
@TestState var mockValues: [String: String]! = [
"/test/extensionCache/conversations": "a",
"/test/extensionCache/conversations/a/config": "b",
"/test/extensionCache/conversations/a/read": "c",
"/test/extensionCache/conversations/a/unread": "d",
"/test/extensionCache/conversations/010203/config": "d",
"/test/extensionCache/conversations/010203/read": "e",
"/test/extensionCache/conversations/010203/unread": "f",
"/test/extensionCache/conversations/0000550000/config": "g",
"/test/extensionCache/conversations/0000550000/read": "h",
"/test/extensionCache/conversations/0000550000/unread": "i"
]
beforeEach {
mockValues.forEach { key, value in
mockFileManager
.when { try $0.contentsOfDirectory(atPath: key) }
.thenReturn([value])
}
mockCrypto
.when {
$0.generate(.hash(
message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!)
))
}
.thenReturn([1, 2, 3])
mockFileManager.when { try $0.contents(atPath: .any) }.thenReturn(Data([1, 2, 3]))
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(
try! JSONEncoder(using: dependencies)
.encode(
SnodeReceivedMessage(
snode: nil,
publicKey: "05\(TestConstants.publicKey)",
namespace: .default,
rawMessage: GetMessagesResponse.RawMessage(
base64EncodedDataString: "TestData",
expirationMs: nil,
hash: "TestHash",
timestampMs: 1234567890
)
)
)
)
let content = SNProtoContent.builder()
let dataMessage = SNProtoDataMessage.builder()
dataMessage.setBody("Test")
content.setDataMessage(try! dataMessage.build())
mockCrypto
.when {
try $0.generate(
.decodedMessage(
encodedMessage: Array<UInt8>.any,
origin: .swarm(
publicKey: .any,
namespace: .default,
serverHash: .any,
serverTimestampMs: .any,
serverExpirationTimestamp: .any
)
)
)
}
.thenReturn(
DecodedMessage(
content: try! content.build().serializedData(),
sender: SessionId(.standard, hex: TestConstants.publicKey),
decodedEnvelope: nil,
sentTimestampMs: 1234567890
)
)
}
// MARK: ---- successfully loads messages
it("successfully loads messages") {
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
let interactions: [Interaction]? = mockStorage.read { try Interaction.fetchAll($0) }
expect(interactions?.count).to(equal(1))
expect(interactions?.map { $0.body }).to(equal(["Test"]))
}
// MARK: ---- always tries to load messages from the current users conversation
it("always tries to load messages from the current users conversation") {
mockCrypto
.when {
$0.generate(.hash(
message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!)
))
}
.thenReturn(Array(Data(hex: "0000550000")))
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/0000550000/config"
)
})
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/0000550000/read"
)
})
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.contentsOfDirectory(
atPath: "/test/extensionCache/conversations/0000550000/unread"
)
})
}
// MARK: ---- loads config messages before other messages
it("loads config messages before other messages") {
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
let key: FunctionConsumer.Key = FunctionConsumer.Key(
name: "contentsOfDirectory(atPath:)",
generics: [],
paramCount: 1
)
let expectedPaths: [String] = [
"/test/extensionCache/conversations/010203/config",
"/test/extensionCache/conversations/010203/read",
"/test/extensionCache/conversations/010203/unread",
"/test/extensionCache/conversations/a/config",
"/test/extensionCache/conversations/a/read",
"/test/extensionCache/conversations/a/unread"
]
let expectedCalls: [CallDetails] = [
CallDetails(
parameterSummary: "[/test/extensionCache/conversations]",
allParameterSummaryCombinations: [
ParameterCombination(count: 0, summary: "[]"),
ParameterCombination(count: 1, summary: "[/test/extensionCache/conversations]")
]
)
]
/// Initial call to load messages
.appending(contentsOf: expectedPaths.map {
CallDetails(
parameterSummary: "[\($0)]",
allParameterSummaryCombinations: [
ParameterCombination(count: 0, summary: "[]"),
ParameterCombination(count: 1, summary: "[\($0)]")
]
)
})
/// Subsequent call to check for empty directory for deletion
.appending(contentsOf: expectedPaths.map {
CallDetails(
parameterSummary: "[\($0)]",
allParameterSummaryCombinations: [
ParameterCombination(count: 0, summary: "[]"),
ParameterCombination(count: 1, summary: "[\($0)]")
]
)
})
expect(mockFileManager.functionConsumer.calls[key]).to(equal(expectedCalls))
}
// MARK: ---- removes messages from disk
it("removes messages from disk") {
mockFileManager.when { try $0.contentsOfDirectory(atPath: .any) }.thenReturn([])
mockCrypto
.when {
$0.generate(.hash(
message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!)
))
}
.thenReturn(Array(Data(hex: "0000550000")))
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.removeItem(
atPath: "/test/extensionCache/conversations/0000550000/config/g"
)
})
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.removeItem(
atPath: "/test/extensionCache/conversations/0000550000/read/h"
)
})
expect(mockFileManager).to(call(matchingParameters: .all) {
try $0.removeItem(
atPath: "/test/extensionCache/conversations/0000550000/unread/i"
)
})
}
// MARK: ---- logs when finished
it("logs when finished") {
await mockLogger.clearLogs() // Clear logs first to make it easier to debug
mockValues.forEach { key, value in
mockFileManager
.when { try $0.contentsOfDirectory(atPath: key) }
.thenReturn([])
}
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") }
.thenReturn(["c"])
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .info,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Finished: Successfully processed 1/1 standard messages, 0/0 config messages.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
}
// MARK: ---- logs an error when failing to process a config message
it("logs an error when failing to process a config message") {
await mockLogger.clearLogs() // Clear logs first to make it easier to debug
mockValues.forEach { key, value in
mockFileManager
.when { try $0.contentsOfDirectory(atPath: key) }
.thenReturn([])
}
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/config") }
.thenReturn(["b"])
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(nil)
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .error,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Discarding some config message changes due to error: Failed to read from file.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .info,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Finished: Successfully processed 0/0 standard messages, 0/1 config messages.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
}
// MARK: ---- logs an error when failing to process a standard message
it("logs an error when failing to process a standard message") {
await mockLogger.clearLogs() // Clear logs first to make it easier to debug
mockValues.forEach { key, value in
mockFileManager
.when { try $0.contentsOfDirectory(atPath: key) }
.thenReturn([])
}
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") }
.thenReturn(["c"])
mockCrypto
.when { $0.generate(.plaintextWithXChaCha20(ciphertext: .any, encKey: .any)) }
.thenReturn(nil)
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .error,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Discarding standard message due to error: Failed to read from file.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .info,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Finished: Successfully processed 0/1 standard messages, 0/0 config messages.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
}
// MARK: ---- succeeds even if it fails to remove files after processing
it("succeeds even if it fails to remove files after processing") {
await mockLogger.clearLogs() // Clear logs first to make it easier to debug
mockValues.forEach { key, value in
mockFileManager
.when { try $0.contentsOfDirectory(atPath: key) }
.thenReturn([])
}
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations") }
.thenReturn(["a"])
mockFileManager
.when { try $0.contentsOfDirectory(atPath: "/test/extensionCache/conversations/a/read") }
.thenReturn(["c"])
mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock)
mockCrypto
.when {
$0.generate(.hash(
message: Array("ConvoIdSalt-05\(TestConstants.publicKey)".data(using: .utf8)!)
))
}
.thenReturn(Array(Data(hex: "0000550000")))
await expect { try await extensionHelper.loadMessages() }.toNot(throwError())
await expect { await mockLogger.logs }.toEventually(contain(
MockLogger.LogOutput(
level: .info,
categories: [
Log.Category.create(
"ExtensionHelper",
group: nil,
customSuffix: "",
defaultLevel: .info
)
],
message: "Finished: Successfully processed 1/1 standard messages, 0/0 config messages.",
file: "SessionMessagingKit/ExtensionHelper.swift",
function: "loadMessages()"
)
))
}
}
}
}
}