You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift

1259 lines
67 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import SessionUtil
import SessionUtilitiesKit
import Quick
import Nimble
@testable import SessionMessagingKit
@testable import SessionSnodeKit
class MessageSenderGroupsSpec: QuickSpec {
override class func spec() {
// MARK: Configuration
let groupSeed: Data = Data(hex: "0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210")
let groupKeyPair: KeyPair = Crypto(using: .any).generate(.ed25519KeyPair(seed: Array(groupSeed)))!
@TestState var groupId: SessionId! = SessionId(.group, hex: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")
@TestState var groupSecretKey: Data! = Data(hex:
"0123456789abcdef0123456789abcdeffedcba9876543210fedcba9876543210" +
"cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece"
)
@TestState var dependencies: TestDependencies! = TestDependencies { dependencies in
dependencies.dateNow = Date(timeIntervalSince1970: 1234567890)
dependencies.forceSynchronous = true
}
@TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage(
customWriter: try! DatabaseQueue(),
migrationTargets: [
SNUtilitiesKit.self,
SNMessagingKit.self
],
using: dependencies,
initialData: { db in
try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).insert(db)
try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).insert(db)
try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db)
try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db)
try Profile(
id: "05\(TestConstants.publicKey)",
name: "TestCurrentUser"
).insert(db)
}
)
@TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults(
initialSetup: { userDefaults in
userDefaults.when { $0.string(forKey: .any) }.thenReturn(nil)
}
)
@TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner(
initialSetup: { jobRunner in
jobRunner
.when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) }
.thenReturn([:])
jobRunner
.when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }
.thenReturn(nil)
jobRunner
.when { $0.upsert(.any, job: .any, canStartJob: .any) }
.thenReturn(nil)
}
)
@TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork(
initialSetup: { network in
network
.when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }
.thenReturn(Network.BatchResponse.mockConfigSyncResponse)
network
.when { $0.getSwarm(for: .any) }
.thenReturn([
LibSession.Snode(
ip: "1.1.1.1",
quicPort: 1,
ed25519PubkeyHex: TestConstants.edPublicKey
),
LibSession.Snode(
ip: "1.1.1.1",
quicPort: 2,
ed25519PubkeyHex: TestConstants.edPublicKey
),
LibSession.Snode(
ip: "1.1.1.1",
quicPort: 3,
ed25519PubkeyHex: TestConstants.edPublicKey
)
])
}
)
@TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto(
initialSetup: { crypto in
crypto
.when { $0.generate(.ed25519KeyPair()) }
.thenReturn(
KeyPair(
publicKey: Data(hex: groupId.hexString).bytes,
secretKey: groupSecretKey.bytes
)
)
crypto
.when { $0.generate(.ed25519KeyPair(seed: .any)) }
.thenReturn(
KeyPair(
publicKey: Data(hex: groupId.hexString).bytes,
secretKey: groupSecretKey.bytes
)
)
crypto
.when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) }
.thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes))
crypto
.when { $0.generate(.memberAuthData(config: .any, groupSessionId: .any, memberId: .any)) }
.thenReturn(Authentication.Info.groupMember(
groupSessionId: SessionId(.standard, hex: TestConstants.publicKey),
authData: "TestAuthData".data(using: .utf8)!
))
crypto
.when { $0.generate(.tokenSubaccount(config: .any, groupSessionId: .any, memberId: .any)) }
.thenReturn(Array("TestSubAccountToken".data(using: .utf8)!))
crypto
.when { try $0.tryGenerate(.randomBytes(.any)) }
.thenReturn(Data((0..<DisplayPictureManager.aes256KeyByteLength).map { _ in 1 }))
crypto
.when { $0.generate(.uuid()) }
.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
crypto
.when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any, using: .any)) }
.thenReturn(TestConstants.validImageData)
}
)
@TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain(
initialSetup: { keychain in
keychain
.when {
try $0.migrateLegacyKeyIfNeeded(
legacyKey: .any,
legacyService: .any,
toKey: .pushNotificationEncryptionKey
)
}
.thenReturn(())
keychain
.when { try $0.data(forKey: .pushNotificationEncryptionKey) }
.thenReturn(Data((0..<PushNotificationAPI.encryptionKeyLength).map { _ in 1 }))
}
)
@TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache(
initialSetup: { cache in
cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey))
}
)
@TestState var secretKey: [UInt8]! = Array(Data(hex: TestConstants.edSecretKey))
@TestState var groupEdPK: [UInt8]! = groupKeyPair.publicKey
@TestState var groupEdSK: [UInt8]! = groupKeyPair.secretKey
@TestState var userGroupsConfig: LibSession.Config! = {
var userGroupsConf: UnsafeMutablePointer<config_object>!
_ = user_groups_init(&userGroupsConf, &secretKey, nil, 0, nil)
return .object(userGroupsConf)
}()
@TestState var groupInfoConf: UnsafeMutablePointer<config_object>! = {
var groupInfoConf: UnsafeMutablePointer<config_object>!
_ = groups_info_init(&groupInfoConf, &groupEdPK, &groupEdSK, nil, 0, nil)
return groupInfoConf
}()
@TestState var groupMembersConf: UnsafeMutablePointer<config_object>! = {
var groupMembersConf: UnsafeMutablePointer<config_object>!
_ = groups_members_init(&groupMembersConf, &groupEdPK, &groupEdSK, nil, 0, nil)
return groupMembersConf
}()
@TestState var groupKeysConf: UnsafeMutablePointer<config_group_keys>! = {
var groupKeysConf: UnsafeMutablePointer<config_group_keys>!
_ = groups_keys_init(&groupKeysConf, &secretKey, &groupEdPK, &groupEdSK, groupInfoConf, groupMembersConf, nil, 0, nil)
return groupKeysConf
}()
@TestState var groupInfoConfig: LibSession.Config! = .object(groupInfoConf)
@TestState var groupMembersConfig: LibSession.Config! = .object(groupMembersConf)
@TestState var groupKeysConfig: LibSession.Config! = {
return .groupKeys(groupKeysConf, info: groupInfoConf, members: groupMembersConf)
}()
@TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache(
initialSetup: { cache in
let userSessionId: SessionId = SessionId(.standard, hex: TestConstants.publicKey)
cache.when { $0.isEmpty }.thenReturn(false)
cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(())
cache.when { $0.removeConfigs(for: .any) }.thenReturn(())
cache
.when { $0.config(for: .userGroups, sessionId: userSessionId) }
.thenReturn(userGroupsConfig)
cache
.when { $0.config(for: .groupInfo, sessionId: groupId) }
.thenReturn(groupInfoConfig)
cache
.when { $0.config(for: .groupMembers, sessionId: groupId) }
.thenReturn(groupMembersConfig)
cache
.when { $0.config(for: .groupKeys, sessionId: groupId) }
.thenReturn(groupKeysConfig)
cache
.when { try $0.pendingChanges(.any, swarmPubkey: .any) }
.thenReturn(LibSession.PendingChanges(obsoleteHashes: ["testHash"]))
cache.when { $0.configNeedsDump(.any) }.thenReturn(false)
cache
.when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) }
.thenReturn(nil)
cache
.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }
.then { args, untrackedArgs in
let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void)
switch args[test: 0] as? ConfigDump.Variant {
case .userGroups: try? callback?(userGroupsConfig)
case .groupInfo: try? callback?(groupInfoConfig)
case .groupMembers: try? callback?(groupMembersConfig)
case .groupKeys: try? callback?(groupKeysConfig)
default: break
}
}
.thenReturn(())
}
)
@TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache(
initialSetup: { cache in
cache.when { $0.clockOffsetMs }.thenReturn(0)
cache.when { $0.currentOffsetTimestampMs() }.thenReturn(1234567890000)
}
)
@TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller(
initialSetup: { cache in
cache.when { $0.startIfNeeded() }.thenReturn(())
}
)
@TestState(cache: .groupPollers, in: dependencies) var mockGroupPollersCache: MockGroupPollerCache! = MockGroupPollerCache(
initialSetup: { cache in
cache.when { $0.startAllPollers() }.thenReturn(())
cache.when { $0.getOrCreatePoller(for: .any) }.thenReturn(mockSwarmPoller)
cache.when { $0.stopAndRemovePoller(for: .any) }.thenReturn(())
cache.when { $0.stopAndRemoveAllPollers() }.thenReturn(())
}
)
@TestState var disposables: [AnyCancellable]! = []
@TestState var error: Error?
@TestState var thread: SessionThread?
// MARK: - a MessageSender dealing with Groups
describe("a MessageSender dealing with Groups") {
// MARK: -- when creating a group
context("when creating a group") {
// MARK: ---- loads the state into the cache
it("loads the state into the cache") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockLibSessionCache)
.to(call(.exactly(times: 1), matchingParameters: .atLeast(2)) { cache in
cache.setConfig(for: .groupInfo, sessionId: groupId, to: .any)
})
expect(mockLibSessionCache)
.to(call(.exactly(times: 1), matchingParameters: .atLeast(2)) { cache in
cache.setConfig(for: .groupMembers, sessionId: groupId, to: .any)
})
expect(mockLibSessionCache)
.to(call(.exactly(times: 1), matchingParameters: .atLeast(2)) { cache in
cache.setConfig(for: .groupKeys, sessionId: groupId, to: .any)
})
}
// MARK: ---- returns the created thread
it("returns the created thread") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.handleEvents(receiveOutput: { result in thread = result })
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
expect(error).to(beNil())
expect(thread).toNot(beNil())
expect(thread?.id).to(equal(groupId.hexString))
expect(thread?.variant).to(equal(.group))
expect(thread?.creationDateTimestamp).to(equal(1234567890))
expect(thread?.shouldBeVisible).to(beTrue())
expect(thread?.messageDraft).to(beNil())
expect(thread?.markedAsUnread).to(beFalse())
expect(thread?.pinnedPriority).to(equal(0))
}
// MARK: ---- stores the thread in the db
it("stores the thread in the db") {
MessageSender
.createGroup(
name: "Test",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.handleEvents(receiveOutput: { result in thread = result })
.sinkAndStore(in: &disposables)
let dbValue: SessionThread? = mockStorage.read { db in try SessionThread.fetchOne(db) }
expect(dbValue).to(equal(thread))
expect(dbValue?.id).to(equal(groupId.hexString))
expect(dbValue?.variant).to(equal(.group))
expect(dbValue?.creationDateTimestamp).to(equal(1234567890))
expect(dbValue?.shouldBeVisible).to(beTrue())
expect(dbValue?.notificationSound).to(beNil())
expect(dbValue?.mutedUntilTimestamp).to(beNil())
expect(dbValue?.onlyNotifyForMentions).to(beFalse())
expect(dbValue?.pinnedPriority).to(equal(0))
}
// MARK: ---- stores the group in the db
it("stores the group in the db") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
let dbValue: ClosedGroup? = mockStorage.read { db in try ClosedGroup.fetchOne(db) }
expect(dbValue?.id).to(equal(groupId.hexString))
expect(dbValue?.name).to(equal("TestGroupName"))
expect(dbValue?.formationTimestamp).to(equal(1234567890))
expect(dbValue?.displayPictureUrl).to(beNil())
expect(dbValue?.displayPictureFilename).to(beNil())
expect(dbValue?.displayPictureEncryptionKey).to(beNil())
expect(dbValue?.lastDisplayPictureUpdate).to(equal(1234567890))
expect(dbValue?.groupIdentityPrivateKey?.toHexString()).to(equal(groupSecretKey.toHexString()))
expect(dbValue?.authData).to(beNil())
expect(dbValue?.invited).to(beFalse())
}
// MARK: ---- stores the group members in the db
it("stores the group members in the db") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockStorage.read { db in try GroupMember.fetchSet(db) })
.to(equal([
GroupMember(
groupId: groupId.hexString,
profileId: "051111111111111111111111111111111111111111111111111111111111111111",
role: .standard,
roleStatus: .pending,
isHidden: false
),
GroupMember(
groupId: groupId.hexString,
profileId: "05\(TestConstants.publicKey)",
role: .admin,
roleStatus: .accepted,
isHidden: false
)
]))
}
// MARK: ---- starts the group poller
it("starts the group poller") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockSwarmPoller)
.to(call(.exactly(times: 1), matchingParameters: .all) { poller in
poller.startIfNeeded()
})
}
// MARK: ---- syncs the group configuration messages
it("syncs the group configuration messages") {
mockLibSessionCache
.when { try $0.pendingChanges(.any, swarmPubkey: .any) }
.thenReturn(
LibSession.PendingChanges(
pushData: [
LibSession.PendingChanges.PushData(
data: Data([1, 2, 3]),
seqNo: 2,
variant: .groupInfo
)
]
)
)
let expectedRequest: Network.PreparedRequest<Network.BatchResponse> = mockStorage.write { db in
// Need the auth data to exist in the database to prepare the request
_ = try SessionThread.fetchOrCreate(
db,
id: groupId.hexString,
variant: .group,
creationDateTimestamp: 0,
shouldBeVisible: nil,
calledFromConfig: nil,
using: dependencies
)
try ClosedGroup(
threadId: groupId.hexString,
name: "Test",
formationTimestamp: 0,
shouldPoll: nil,
groupIdentityPrivateKey: groupSecretKey,
invited: nil
).upsert(db)
let preparedRequest: Network.PreparedRequest<Network.BatchResponse> = try SnodeAPI.preparedSequence(
requests: [
try SnodeAPI
.preparedSendMessage(
message: SnodeMessage(
recipient: groupId.hexString,
data: Data([1, 2, 3]).base64EncodedString(),
ttl: ConfigDump.Variant.groupInfo.ttl,
timestampMs: 1234567890
),
in: ConfigDump.Variant.groupInfo.namespace,
authMethod: try Authentication.with(
db,
swarmPublicKey: groupId.hexString,
using: dependencies
),
using: dependencies
)
],
requireAllBatchResponses: false,
swarmPublicKey: groupId.hexString,
using: dependencies
)
// Remove the debug group so it can be created during the actual test
try ClosedGroup.filter(id: groupId.hexString).deleteAll(db)
try SessionThread.filter(id: groupId.hexString).deleteAll(db)
return preparedRequest
}!
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockNetwork)
.to(call(.exactly(times: 1), matchingParameters: .all) { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ---- and the group configuration sync fails
context("and the group configuration sync fails") {
beforeEach {
mockNetwork
.when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }
.thenReturn(MockNetwork.errorResponse())
}
// MARK: ------ throws an error
it("throws an error") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
expect(error).to(matchError(TestError.mock))
}
// MARK: ------ removes the config state
it("removes the config state") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
expect(mockLibSessionCache)
.to(call(.exactly(times: 1), matchingParameters: .all) { cache in
cache.removeConfigs(for: groupId)
})
}
// MARK: ------ removes the data from the database
it("removes the data from the database") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) }
let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) }
let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) }
expect(threads).to(beEmpty())
expect(groups).to(beEmpty())
expect(members).to(beEmpty())
}
}
// MARK: ------ does not upload an image if none is provided
it("does not upload an image if none is provided") {
// Prevent the ConfigSyncJob network request by making the libSession cache appear empty
mockLibSessionCache.when { $0.isEmpty }.thenReturn(true)
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
let expectedRequest: Network.PreparedRequest<FileUploadResponse> = try Network
.preparedUpload(data: TestConstants.validImageData, using: dependencies)
expect(mockNetwork)
.toNot(call { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ------ with an image
context("with an image") {
// MARK: ------ uploads the image
it("uploads the image") {
mockNetwork
.when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }
.thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1")))
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: TestConstants.validImageData,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
let expectedRequest: Network.PreparedRequest<FileUploadResponse> = try Network
.preparedUpload(data: TestConstants.validImageData, using: dependencies)
expect(mockNetwork)
.to(call(.exactly(times: 1), matchingParameters: .all) { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ------ saves the image info to the group
it("saves the image info to the group") {
// Prevent the ConfigSyncJob network request by making the libSession cache appear empty
mockLibSessionCache.when { $0.isEmpty }.thenReturn(true)
mockNetwork
.when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }
.thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1")))
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: TestConstants.validImageData,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) }
expect(groups?.first?.displayPictureUrl).to(equal("http://filev2.getsession.org/file/1"))
expect(groups?.first?.displayPictureFilename)
.to(equal("00000000-0000-0000-0000-000000000000.jpg"))
expect(groups?.first?.displayPictureEncryptionKey)
.to(equal(Data((0..<DisplayPictureManager.aes256KeyByteLength).map { _ in 1 })))
}
// MARK: ------ fails if the image fails to upload
it("fails if the image fails to upload") {
mockNetwork
.when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }
.thenReturn(Fail(error: NetworkError.unknown).eraseToAnyPublisher())
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: TestConstants.validImageData,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.mapError { error.setting(to: $0) }
.sinkAndStore(in: &disposables)
expect(error).to(matchError(DisplayPictureError.uploadFailed))
}
}
// MARK: ---- schedules member invite jobs
it("schedules member invite jobs") {
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockJobRunner)
.to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in
jobRunner.add(
.any,
job: Job(
variant: .groupInviteMember,
threadId: groupId.hexString,
details: try? GroupInviteMemberJob.Details(
memberSessionIdHexString: "051111111111111111111111111111111111111111111111111111111111111111",
authInfo: .groupMember(
groupSessionId: SessionId(.standard, hex: TestConstants.publicKey),
authData: "TestAuthData".data(using: .utf8)!
)
)
),
dependantJob: nil,
canStartJob: true
)
})
}
// MARK: ------ and trying to subscribe for push notifications
context("and trying to subscribe for push notifications") {
@TestState var expectedRequest: Network.PreparedRequest<PushNotificationAPI.SubscribeResponse>!
beforeEach {
// Need to set `isUsingFullAPNs` to true to generate the `expectedRequest`
mockUserDefaults
.when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) }
.thenReturn(Data([5, 4, 3, 2, 1]).toHexString())
mockUserDefaults
.when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) }
.thenReturn(true)
expectedRequest = mockStorage.write { db in
_ = try SessionThread.fetchOrCreate(
db,
id: groupId.hexString,
variant: .group,
creationDateTimestamp: 0,
shouldBeVisible: nil,
calledFromConfig: nil,
using: dependencies
)
try ClosedGroup(
threadId: groupId.hexString,
name: "Test",
formationTimestamp: 0,
shouldPoll: nil,
groupIdentityPrivateKey: groupSecretKey,
invited: nil
).upsert(db)
let result = try PushNotificationAPI.preparedSubscribe(
db,
token: Data([5, 4, 3, 2, 1]),
sessionIds: [groupId],
using: dependencies
)
// Remove the debug group so it can be created during the actual test
try ClosedGroup.filter(id: groupId.hexString).deleteAll(db)
try SessionThread.filter(id: groupId.hexString).deleteAll(db)
return result
}!
}
// MARK: ---- subscribes when they are enabled
it("subscribes when they are enabled") {
mockUserDefaults
.when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) }
.thenReturn(Data([5, 4, 3, 2, 1]).toHexString())
mockUserDefaults
.when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) }
.thenReturn(true)
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockNetwork)
.to(call(.exactly(times: 1), matchingParameters: .all) { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ---- does not subscribe if push notifications are disabled
it("does not subscribe if push notifications are disabled") {
// Prevent the ConfigSyncJob network request by making the libSession cache appear empty
mockLibSessionCache.when { $0.isEmpty }.thenReturn(true)
mockUserDefaults
.when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) }
.thenReturn(Data([5, 4, 3, 2, 1]).toHexString())
mockUserDefaults
.when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) }
.thenReturn(false)
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockNetwork).toNot(call { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ---- does not subscribe if there is no push token
it("does not subscribe if there is no push token") {
// Prevent the ConfigSyncJob network request by making the libSession cache appear empty
mockLibSessionCache.when { $0.isEmpty }.thenReturn(true)
mockUserDefaults
.when { $0.string(forKey: UserDefaults.StringKey.deviceToken.rawValue) }
.thenReturn(nil)
mockUserDefaults
.when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) }
.thenReturn(true)
MessageSender
.createGroup(
name: "TestGroupName",
description: nil,
displayPictureData: nil,
members: [
("051111111111111111111111111111111111111111111111111111111111111111", nil)
],
using: dependencies
)
.sinkAndStore(in: &disposables)
expect(mockNetwork).toNot(call { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
}
// MARK: -- when adding members to a group
context("when adding members to a group") {
beforeEach {
// Rekey a couple of times to increase the key generation to 1
var fakeHash1: [CChar] = "fakehash1".cString(using: .utf8)!
var fakeHash2: [CChar] = "fakehash2".cString(using: .utf8)!
var pushResult: UnsafePointer<UInt8>? = nil
var pushResultLen: Int = 0
_ = groups_keys_rekey(groupKeysConf, groupInfoConf, groupMembersConf, &pushResult, &pushResultLen)
_ = groups_keys_load_message(groupKeysConf, &fakeHash1, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf)
_ = groups_keys_rekey(groupKeysConf, groupInfoConf, groupMembersConf, &pushResult, &pushResultLen)
_ = groups_keys_load_message(groupKeysConf, &fakeHash2, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf)
mockStorage.write { db in
try SessionThread.fetchOrCreate(
db,
id: groupId.hexString,
variant: .group,
creationDateTimestamp: 1234567890,
shouldBeVisible: true,
calledFromConfig: nil,
using: dependencies
)
try ClosedGroup(
threadId: groupId.hexString,
name: "TestGroup",
formationTimestamp: 1234567890,
shouldPoll: true,
groupIdentityPrivateKey: groupSecretKey,
authData: nil,
invited: false
).upsert(db)
}
}
// MARK: ---- does nothing if the current user is not an admin
it("does nothing if the current user is not an admin") {
mockStorage.write { db in
try ClosedGroup
.updateAll(
db,
ClosedGroup.Columns.groupIdentityPrivateKey.set(to: nil)
)
}
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: false,
using: dependencies
).sinkUntilComplete()
let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) }
expect(groups_members_size(groupMembersConf)).to(equal(0))
expect(members?.count).to(equal(0))
}
// MARK: ---- adds the member to the database in the notSentYet state
it("adds the member to the database in the notSentYet state") {
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: false,
using: dependencies
).sinkUntilComplete()
let members: [GroupMember]? = mockStorage.read { db in try GroupMember.fetchAll(db) }
expect(members?.count).to(equal(1))
expect(members?.first?.profileId)
.to(equal("051111111111111111111111111111111111111111111111111111111111111112"))
expect(members?.first?.role).to(equal(.standard))
expect(members?.first?.roleStatus).to(equal(.notSentYet))
}
// MARK: ---- adds the member to GROUP_MEMBERS
it("adds the member to GROUP_MEMBERS") {
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: false,
using: dependencies
).sinkUntilComplete()
expect(groups_members_size(groupMembersConf)).to(equal(1))
let members: Set<GroupMember>? = try? LibSession.extractMembers(
from: groupMembersConf,
groupSessionId: groupId
)
expect(members?.count).to(equal(1))
expect(members?.first?.profileId)
.to(equal("051111111111111111111111111111111111111111111111111111111111111112"))
expect(members?.first?.role).to(equal(.standard))
expect(members?.first?.roleStatus).to(equal(.notSentYet))
}
// MARK: ---- and granting access to historic messages
context("and granting access to historic messages") {
// MARK: ---- performs a supplemental key rotation
it("performs a supplemental key rotation") {
let initialKeyRotation: Int = try LibSession.currentGeneration(
groupSessionId: groupId,
using: dependencies
)
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
// Can't actually detect a supplemental rotation directly but can check that the
// keys generation didn't increase
let result: Int = try LibSession.currentGeneration(
groupSessionId: groupId,
using: dependencies
)
expect(result).to(equal(initialKeyRotation))
}
// MARK: ---- sends the supplemental key rotation data
it("sends the supplemental key rotation data") {
let requestDataString: String = "ZDE6IzI0OhOKDnbpLN3QJVbKzR8mOmjn6gXmeUFdTDE6K" +
"2wxNDA669s6Q2aETGZ5agGXfVVrC8Q9JA4bIoqv5iWyQWjttPhqDK2IZHXGVDZ/Kaz9tEq2Rl" +
"r2B9/neDBUFPtH3haJFN/zkIq1dAIwkgQQ4xJK00zWvZt6HejV1Fy6W9eI1oRJJny0++5+hxp" +
"LPczVOFKOPs+rrB3aUpMsNUnJHOEhW9g6zi/UPjuCWTnnvpxlMTpHaTFlMTp+NjQ6dKi86jZJ" +
"l3oiJEA5h5pBE5oOJHQNvtF8GOcsYwrIFTZKnI7AGkBSu1TxP0xLWwTUzjOGMgmKvlIgkQ6e9" +
"r3JBmU="
let expectedRequest: Network.PreparedRequest<SendMessagesResponse> = try SnodeAPI
.preparedSendMessage(
message: SnodeMessage(
recipient: groupId.hexString,
data: requestDataString,
ttl: ConfigDump.Variant.groupKeys.ttl,
timestampMs: UInt64(1234567890000)
),
in: .configGroupKeys,
authMethod: Authentication.groupAdmin(
groupSessionId: groupId,
ed25519SecretKey: Array(groupSecretKey)
),
using: dependencies
)
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
// If there is a pending keys config then merge it to complete the process
var pushResult: UnsafePointer<UInt8>? = nil
var pushResultLen: Int = 0
if groups_keys_pending_config(groupKeysConf, &pushResult, &pushResultLen) {
// Rekey a couple of times to increase the key generation to 1
var fakeHash3: [CChar] = "fakehash3".cString(using: .utf8)!
_ = groups_keys_load_message(groupKeysConf, &fakeHash3, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf)
}
expect(mockNetwork)
.to(call(.exactly(times: 1), matchingParameters: .all) { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
}
// MARK: ---- and not granting access to historic messages
context("and not granting access to historic messages") {
// MARK: ---- performs a full key rotation
it("performs a full key rotation") {
let initialKeyRotation: Int = try LibSession.currentGeneration(
groupSessionId: groupId,
using: dependencies
)
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: false,
using: dependencies
).sinkUntilComplete()
// If there is a pending keys config then merge it to complete the process
var pushResult: UnsafePointer<UInt8>? = nil
var pushResultLen: Int = 0
if groups_keys_pending_config(groupKeysConf, &pushResult, &pushResultLen) {
// Rekey a couple of times to increase the key generation to 1
var fakeHash3: [CChar] = "fakehash3".cString(using: .utf8)!
_ = groups_keys_load_message(groupKeysConf, &fakeHash3, pushResult, pushResultLen, 1234567890, groupInfoConf, groupMembersConf)
}
let result: Int = try LibSession.currentGeneration(
groupSessionId: groupId,
using: dependencies
)
expect(result).to(beGreaterThan(initialKeyRotation))
}
}
// MARK: ---- calls the unrevoke subaccounts endpoint
it("calls the unrevoke subaccounts endpoint") {
let expectedRequest: Network.PreparedRequest<Void> = try SnodeAPI
.preparedUnrevokeSubaccounts(
subaccountsToUnrevoke: [Array("TestSubAccountToken".data(using: .utf8)!)],
authMethod: Authentication.groupAdmin(
groupSessionId: groupId,
ed25519SecretKey: Array(groupSecretKey)
),
using: dependencies
)
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
expect(mockNetwork)
.to(call(.exactly(times: 1), matchingParameters: .all) { network in
network.send(
expectedRequest.body,
to: expectedRequest.destination,
requestTimeout: expectedRequest.requestTimeout,
requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout
)
})
}
// MARK: ---- schedules member invite jobs
it("schedules member invite jobs") {
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
expect(mockJobRunner)
.to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in
jobRunner.add(
.any,
job: Job(
variant: .groupInviteMember,
threadId: groupId.hexString,
details: try? GroupInviteMemberJob.Details(
memberSessionIdHexString: "051111111111111111111111111111111111111111111111111111111111111112",
authInfo: .groupMember(
groupSessionId: SessionId(.standard, hex: TestConstants.publicKey),
authData: "TestAuthData".data(using: .utf8)!
)
)
),
dependantJob: nil,
canStartJob: true
)
})
}
// MARK: ---- adds a member change control message
it("adds a member change control message") {
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
let interactions: [Interaction]? = mockStorage.read { db in try Interaction.fetchAll(db) }
expect(interactions?.count).to(equal(1))
expect(interactions?.first?.variant).to(equal(.infoGroupMembersUpdated))
expect(interactions?.first?.body).to(equal(
ClosedGroup.MessageInfo
.addedUsers(
hasCurrentUser: false,
names: ["0511...1112"],
historyShared: true
)
.infoString(using: dependencies)
))
}
// MARK: ---- schedules sending of the member change message
it("schedules sending of the member change message") {
MessageSender.addGroupMembers(
groupSessionId: groupId.hexString,
members: [
("051111111111111111111111111111111111111111111111111111111111111112", nil)
],
allowAccessToHistoricMessages: true,
using: dependencies
).sinkUntilComplete()
expect(mockJobRunner)
.to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in
jobRunner.add(
.any,
job: Job(
variant: .messageSend,
threadId: groupId.hexString,
interactionId: nil,
details: MessageSendJob.Details(
destination: .closedGroup(groupPublicKey: groupId.hexString),
message: try! GroupUpdateMemberChangeMessage(
changeType: .added,
memberSessionIds: [
"051111111111111111111111111111111111111111111111111111111111111112"
],
historyShared: true,
sentTimestampMs: 1234567890000,
authMethod: Authentication.groupAdmin(
groupSessionId: groupId,
ed25519SecretKey: Array(groupSecretKey)
),
using: dependencies
)
)
),
dependantJob: nil,
canStartJob: true
)
})
}
}
}
}
}
}
// MARK: - Mock Types
extension SendMessagesResponse: Mocked {
static var mock: SendMessagesResponse = SendMessagesResponse(
hash: "hash",
swarm: [:],
hardFork: [1, 2],
timeOffset: 0
)
}
// MARK: - Mock Batch Responses
extension Network.BatchResponse {
// MARK: - Valid Responses
fileprivate static let mockConfigSyncResponse: AnyPublisher<(ResponseInfoType, Data?), Error> = MockNetwork.batchResponseData(
with: [
(SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()),
(SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse()),
(SnodeAPI.Endpoint.sendMessage, SendMessagesResponse.mockBatchSubResponse())
]
)
}